@botiverse/porthole 0.0.0-stage → 0.1.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/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +184 -0
- package/dist/bridge/types.d.ts +67 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +106 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +131 -0
- package/dist/config/schema.js +1 -0
- package/dist/config/validate.d.ts +20 -0
- package/dist/config/validate.js +230 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +376 -0
- package/dist/conformance/index.d.ts +95 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +9 -0
- package/dist/conformance/probes.js +865 -0
- package/dist/conformance/served.d.ts +68 -0
- package/dist/conformance/served.js +311 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +261 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +248 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +11 -0
- package/dist/host/types.d.ts +95 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +19 -0
- package/dist/isolation/served.d.ts +32 -0
- package/dist/isolation/served.js +97 -0
- package/dist/isolation/types.d.ts +87 -0
- package/dist/isolation/types.js +1 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +44 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +72 -0
- package/dist/presets/presets.d.ts +65 -0
- package/dist/presets/presets.js +125 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +75 -0
- package/dist/react/frame.js +221 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +6 -0
- package/dist/server/pages.js +20 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +518 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +28 -0
- package/dist/shell/document.js +84 -0
- package/dist/shell/plan.d.ts +35 -0
- package/dist/shell/plan.js +34 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +832 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +68 -3
- package/src/bridge/bridge.ts +256 -0
- package/src/bridge/types.ts +77 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +133 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +140 -0
- package/src/config/validate.ts +284 -0
- package/src/conformance/behavior.ts +430 -0
- package/src/conformance/index.ts +102 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1093 -0
- package/src/conformance/served.ts +472 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +424 -0
- package/src/host/create-host.ts +290 -0
- package/src/host/locate.ts +20 -0
- package/src/host/types.ts +95 -0
- package/src/index.ts +118 -0
- package/src/isolation/served.ts +127 -0
- package/src/isolation/types.ts +77 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +48 -0
- package/src/platform/web.ts +78 -0
- package/src/presets/presets.ts +193 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +422 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +31 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +617 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +120 -0
- package/src/shell/plan.ts +66 -0
- package/src/shell/runtime.ts +832 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
- package/README.md +0 -3
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@botiverse/porthole/mcp`: the MCP Apps adapter for full MCP Apps views (P007): the host-side
|
|
3
|
+
* subset of the protocol (the handshake and host context, tool input and result, cancellation,
|
|
4
|
+
* teardown, sizes and errors; `ui/message`, `ui/open-link`, `ui/download-file`, `tools/call`,
|
|
5
|
+
* `resources/read` and `ui/update-model-context` through the bridge; the sandbox-proxy handshake),
|
|
6
|
+
* implemented as a `ProtocolAdapter` against the core's seams. It depends on the core only, never
|
|
7
|
+
* on the MCP SDK.
|
|
8
|
+
*
|
|
9
|
+
* const view = readViewResource(await client.readResource({ uri }));
|
|
10
|
+
* const host = createHost(presets.mcpApp({ egress: view.egress, capabilities: { callTool: {} } }), {
|
|
11
|
+
* adapter: mcpAppsAdapter(),
|
|
12
|
+
* handlers: { callTool: ({ name, arguments: args }) => client.callTool({ name, arguments: args }) },
|
|
13
|
+
* });
|
|
14
|
+
* const display = host.mount(element, { displayId, title, scheme, document: view.html });
|
|
15
|
+
* display.sendToolInput(args);
|
|
16
|
+
* display.sendToolResult(await client.callTool({ name, arguments: args }));
|
|
17
|
+
*
|
|
18
|
+
* Tool visibility (`_meta.ui.visibility`) is the host's policy: its `callTool` handler refuses a
|
|
19
|
+
* tool the view may not call.
|
|
20
|
+
*/
|
|
21
|
+
export { mcpAppsAdapter, viewHostContext, } from "./adapter.js";
|
|
22
|
+
export { sandboxProxyDocument } from "./proxy.js";
|
|
23
|
+
export { VIEW_MIME_TYPE, readViewResource, viewEgress } from "./view.js";
|
|
24
|
+
export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "../protocol/mcp-apps.js";
|
|
25
|
+
export { JSON_RPC_ERRORS, parseJsonRpc, } from "../protocol/jsonrpc.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CompiledConfig } from "../compile/compile.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The MCP Apps sandbox proxy (spec § Sandbox proxy), served by `/server` on a scope's origin: a
|
|
4
|
+
* second origin, never the host's, where a view gets `allow-scripts allow-same-origin` (P016).
|
|
5
|
+
* It announces itself (`sandbox-proxy-ready`) until its host answers with the view's HTML
|
|
6
|
+
* (`sandbox-resource-ready`), then becomes the view: it writes the shell's document into its own,
|
|
7
|
+
* with the view's HTML as data in the shell's plan (never as markup for the page's parser, which
|
|
8
|
+
* could build what the shell's guards can't see), and the shell renders it inert, guarded, then
|
|
9
|
+
* runs it, under the CSP the proxy's headers already carry. Writing a document erases
|
|
10
|
+
* the window's listeners, so nothing of the proxy is left once the view runs, and the view talks
|
|
11
|
+
* to its host directly: the relay the spec describes is the identity here (P026).
|
|
12
|
+
*
|
|
13
|
+
* The proxy takes the HTML only from its parent, only from the host origins, and only once. It
|
|
14
|
+
* ignores the message's `csp`, `sandbox` and `permissions`: the policy is the one its headers set
|
|
15
|
+
* from the config and the view's declared egress when the host's backend issued it, and the
|
|
16
|
+
* frame's sandbox and `allow` are the host's, from the same config.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SANDBOX_PROXY_RUNTIME: string;
|
|
19
|
+
/**
|
|
20
|
+
* The proxy's document for a view under `compiled` (an `mcp-app` config with domain isolation),
|
|
21
|
+
* taking the view only from `hostOrigins`. The document it becomes is exactly
|
|
22
|
+
* `compiled.shellDocument({ origins: hostOrigins, document: html, cspMeta: false })`: the shell,
|
|
23
|
+
* carrying the view as data.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sandboxProxyDocument(compiled: CompiledConfig, hostOrigins: readonly string[]): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { MCP_APPS_METHODS } from "../protocol/mcp-apps.js";
|
|
2
|
+
/**
|
|
3
|
+
* The MCP Apps sandbox proxy (spec § Sandbox proxy), served by `/server` on a scope's origin: a
|
|
4
|
+
* second origin, never the host's, where a view gets `allow-scripts allow-same-origin` (P016).
|
|
5
|
+
* It announces itself (`sandbox-proxy-ready`) until its host answers with the view's HTML
|
|
6
|
+
* (`sandbox-resource-ready`), then becomes the view: it writes the shell's document into its own,
|
|
7
|
+
* with the view's HTML as data in the shell's plan (never as markup for the page's parser, which
|
|
8
|
+
* could build what the shell's guards can't see), and the shell renders it inert, guarded, then
|
|
9
|
+
* runs it, under the CSP the proxy's headers already carry. Writing a document erases
|
|
10
|
+
* the window's listeners, so nothing of the proxy is left once the view runs, and the view talks
|
|
11
|
+
* to its host directly: the relay the spec describes is the identity here (P026).
|
|
12
|
+
*
|
|
13
|
+
* The proxy takes the HTML only from its parent, only from the host origins, and only once. It
|
|
14
|
+
* ignores the message's `csp`, `sandbox` and `permissions`: the policy is the one its headers set
|
|
15
|
+
* from the config and the view's declared egress when the host's backend issued it, and the
|
|
16
|
+
* frame's sandbox and `allow` are the host's, from the same config.
|
|
17
|
+
*/
|
|
18
|
+
export const SANDBOX_PROXY_RUNTIME = String.raw `(() => {
|
|
19
|
+
"use strict";
|
|
20
|
+
const setup = __SETUP__;
|
|
21
|
+
if (window.parent === window) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let done = false;
|
|
25
|
+
window.addEventListener("message", (event) => {
|
|
26
|
+
if (done || event.source !== window.parent || setup.origins.indexOf(event.origin) === -1) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const data = event.data;
|
|
30
|
+
if (data === null || typeof data !== "object" || data.jsonrpc !== "2.0" || data.method !== setup.resource) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const params = data.params;
|
|
34
|
+
if (params === null || typeof params !== "object" || typeof params.html !== "string") {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
done = true;
|
|
38
|
+
// The view goes into the shell's plan as a JSON string, safe inside its <script>.
|
|
39
|
+
const literal = JSON.stringify(params.html)
|
|
40
|
+
.replace(/</g, "\\u003c")
|
|
41
|
+
.replace(/\u2028/g, "\\u2028")
|
|
42
|
+
.replace(/\u2029/g, "\\u2029");
|
|
43
|
+
const page = setup.shell.replace(setup.slot, () => literal);
|
|
44
|
+
document.open();
|
|
45
|
+
document.write(page);
|
|
46
|
+
document.close();
|
|
47
|
+
});
|
|
48
|
+
// Say so until answered: the host may start listening after this runs. Nothing secret is said.
|
|
49
|
+
let tries = 0;
|
|
50
|
+
const announce = () => {
|
|
51
|
+
if (done || tries >= 40) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
tries += 1;
|
|
55
|
+
window.parent.postMessage({ jsonrpc: "2.0", method: setup.ready, params: {} }, "*");
|
|
56
|
+
setTimeout(announce, 250);
|
|
57
|
+
};
|
|
58
|
+
announce();
|
|
59
|
+
})();`;
|
|
60
|
+
/** Where the view's HTML goes in the shell's plan: a string no view can produce as JSON. */
|
|
61
|
+
const VIEW_SLOT = "porthole-view-slot-7f3a9c";
|
|
62
|
+
/** JSON that is safe inside a `<script>`: no `</script>`, no `<!--`. */
|
|
63
|
+
const scriptJson = (value) => JSON.stringify(value)
|
|
64
|
+
.replace(/</gu, "\\u003c")
|
|
65
|
+
.replace(/\u2028/gu, "\\u2028")
|
|
66
|
+
.replace(/\u2029/gu, "\\u2029");
|
|
67
|
+
/**
|
|
68
|
+
* The proxy's document for a view under `compiled` (an `mcp-app` config with domain isolation),
|
|
69
|
+
* taking the view only from `hostOrigins`. The document it becomes is exactly
|
|
70
|
+
* `compiled.shellDocument({ origins: hostOrigins, document: html, cspMeta: false })`: the shell,
|
|
71
|
+
* carrying the view as data.
|
|
72
|
+
*/
|
|
73
|
+
export function sandboxProxyDocument(compiled, hostOrigins) {
|
|
74
|
+
if (compiled.config.mode !== "mcp-app") {
|
|
75
|
+
throw new TypeError(`The sandbox proxy serves MCP Apps views, not ${compiled.config.mode}`);
|
|
76
|
+
}
|
|
77
|
+
// The shell with a slot for the view: the slot's JSON literal, replaced by the view's.
|
|
78
|
+
const shell = compiled.shellDocument({
|
|
79
|
+
origins: hostOrigins,
|
|
80
|
+
document: VIEW_SLOT,
|
|
81
|
+
cspMeta: false,
|
|
82
|
+
});
|
|
83
|
+
const slot = JSON.stringify(VIEW_SLOT);
|
|
84
|
+
if (shell.split(slot).length !== 2) {
|
|
85
|
+
throw new Error("The shell document must hold the view's slot exactly once");
|
|
86
|
+
}
|
|
87
|
+
const runtime = SANDBOX_PROXY_RUNTIME.replace("__SETUP__", () => scriptJson({
|
|
88
|
+
origins: hostOrigins,
|
|
89
|
+
shell,
|
|
90
|
+
slot,
|
|
91
|
+
ready: MCP_APPS_METHODS.sandboxProxyReady,
|
|
92
|
+
resource: MCP_APPS_METHODS.sandboxResourceReady,
|
|
93
|
+
}));
|
|
94
|
+
return `<!doctype html>
|
|
95
|
+
<html lang="en">
|
|
96
|
+
<head>
|
|
97
|
+
<meta charset="utf-8">
|
|
98
|
+
<meta name="referrer" content="no-referrer">
|
|
99
|
+
<title>Sandbox</title>
|
|
100
|
+
</head>
|
|
101
|
+
<body>
|
|
102
|
+
<script>${runtime}</script>
|
|
103
|
+
</body>
|
|
104
|
+
</html>
|
|
105
|
+
`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { EgressConfig } from "../config/schema.ts";
|
|
2
|
+
/**
|
|
3
|
+
* What a host reads from an MCP Apps view's resource before showing it: the HTML, and the egress
|
|
4
|
+
* the view declares, in Porthole's vocabulary so it goes into a config (P003).
|
|
5
|
+
*/
|
|
6
|
+
/** The style variables MCP Apps standardizes: one list, the core's (P028). */
|
|
7
|
+
export { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.ts";
|
|
8
|
+
/**
|
|
9
|
+
* A view's `_meta.ui.csp` as egress: `connectDomains` to connect, `resourceDomains` to scripts,
|
|
10
|
+
* styles, images, fonts and media. What isn't an exact origin (or `https://*.` one) is dropped,
|
|
11
|
+
* never widened. `frameDomains` and `baseUriDomains` open nothing: the shell removes nested frames
|
|
12
|
+
* (P006) and `base-uri` stays `'none'`, so a view that needs either runs without it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function viewEgress(csp: unknown): EgressConfig;
|
|
15
|
+
/** A view, as `resources/read` gives it. */
|
|
16
|
+
export interface ViewResource {
|
|
17
|
+
readonly uri: string;
|
|
18
|
+
/** The view's whole document. */
|
|
19
|
+
readonly html: string;
|
|
20
|
+
/** What the view declares it reaches (its `_meta.ui.csp`), as egress. */
|
|
21
|
+
readonly egress: EgressConfig;
|
|
22
|
+
/** Whether the view asks the host for a border and background (`_meta.ui.prefersBorder`). */
|
|
23
|
+
readonly prefersBorder?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** The MIME type of an MCP Apps view. */
|
|
26
|
+
export declare const VIEW_MIME_TYPE = "text/html;profile=mcp-app";
|
|
27
|
+
/**
|
|
28
|
+
* The view in a `resources/read` result: its first content of the MCP Apps MIME type, as text or
|
|
29
|
+
* base64. Null when there is none.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readViewResource(result: unknown): ViewResource | null;
|
package/dist/mcp/view.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isOriginSource } from "../config/validate.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a host reads from an MCP Apps view's resource before showing it: the HTML, and the egress
|
|
4
|
+
* the view declares, in Porthole's vocabulary so it goes into a config (P003).
|
|
5
|
+
*/
|
|
6
|
+
/** The style variables MCP Apps standardizes: one list, the core's (P028). */
|
|
7
|
+
export { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.js";
|
|
8
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
/** A declared domain as a CSP source Porthole accepts, or null (`'self'`, `*`, paths, quotes). */
|
|
10
|
+
function originOf(value) {
|
|
11
|
+
if (typeof value !== "string") {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const source = value.trim().toLowerCase().replace(/\/$/u, "");
|
|
15
|
+
return isOriginSource(source) ? source : null;
|
|
16
|
+
}
|
|
17
|
+
const origins = (value) => Array.isArray(value)
|
|
18
|
+
? [...new Set(value.map(originOf).filter((each) => each !== null))]
|
|
19
|
+
: [];
|
|
20
|
+
/**
|
|
21
|
+
* A view's `_meta.ui.csp` as egress: `connectDomains` to connect, `resourceDomains` to scripts,
|
|
22
|
+
* styles, images, fonts and media. What isn't an exact origin (or `https://*.` one) is dropped,
|
|
23
|
+
* never widened. `frameDomains` and `baseUriDomains` open nothing: the shell removes nested frames
|
|
24
|
+
* (P006) and `base-uri` stays `'none'`, so a view that needs either runs without it.
|
|
25
|
+
*/
|
|
26
|
+
export function viewEgress(csp) {
|
|
27
|
+
if (!isRecord(csp)) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
const connect = origins(csp.connectDomains);
|
|
31
|
+
const resource = origins(csp.resourceDomains);
|
|
32
|
+
return {
|
|
33
|
+
...(connect.length > 0 ? { connect } : {}),
|
|
34
|
+
...(resource.length > 0
|
|
35
|
+
? { script: resource, style: resource, img: resource, font: resource, media: resource }
|
|
36
|
+
: {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The MIME type of an MCP Apps view. */
|
|
40
|
+
export const VIEW_MIME_TYPE = "text/html;profile=mcp-app";
|
|
41
|
+
/**
|
|
42
|
+
* The view in a `resources/read` result: its first content of the MCP Apps MIME type, as text or
|
|
43
|
+
* base64. Null when there is none.
|
|
44
|
+
*/
|
|
45
|
+
export function readViewResource(result) {
|
|
46
|
+
const contents = isRecord(result) && Array.isArray(result.contents) ? result.contents : [];
|
|
47
|
+
for (const content of contents) {
|
|
48
|
+
if (!isRecord(content) || typeof content.mimeType !== "string") {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (content.mimeType.replace(/\s/gu, "").toLowerCase() !== VIEW_MIME_TYPE) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const html = typeof content.text === "string"
|
|
55
|
+
? content.text
|
|
56
|
+
: typeof content.blob === "string"
|
|
57
|
+
? new TextDecoder().decode(Uint8Array.from(atob(content.blob), (char) => char.charCodeAt(0)))
|
|
58
|
+
: null;
|
|
59
|
+
if (html === null) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const meta = content["_meta"];
|
|
63
|
+
const ui = isRecord(meta) && isRecord(meta.ui) ? meta.ui : {};
|
|
64
|
+
return {
|
|
65
|
+
uri: typeof content.uri === "string" ? content.uri : "",
|
|
66
|
+
html,
|
|
67
|
+
egress: viewEgress(ui.csp),
|
|
68
|
+
...(typeof ui.prefersBorder === "boolean" ? { prefersBorder: ui.prefersBorder } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ColorScheme } from "../config/schema.ts";
|
|
2
|
+
import type { FrameSource } from "../isolation/types.ts";
|
|
3
|
+
import type { Transport } from "../transport/types.ts";
|
|
4
|
+
/**
|
|
5
|
+
* The platform-host seam (ARCHITECTURE § Platform hosts): what owns the isolation primitive and
|
|
6
|
+
* the transport. The web's is an `<iframe>` with `postMessage`; a native web view is another
|
|
7
|
+
* implementation of the same interface, not a fork.
|
|
8
|
+
*/
|
|
9
|
+
/** What to create: the frame's document and its policy attributes, all from the compiled config. */
|
|
10
|
+
export interface SurfaceSpec {
|
|
11
|
+
readonly source: FrameSource;
|
|
12
|
+
/** The frame's accessible name. */
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly sandbox: string;
|
|
15
|
+
readonly allow: string;
|
|
16
|
+
readonly referrerPolicy: "no-referrer";
|
|
17
|
+
readonly colorScheme: ColorScheme;
|
|
18
|
+
/** The height to start at (a remembered height, so a frame mounting again doesn't jump). */
|
|
19
|
+
readonly initialHeightPx: number;
|
|
20
|
+
}
|
|
21
|
+
/** One isolated frame. */
|
|
22
|
+
export interface Surface {
|
|
23
|
+
readonly transport: Transport;
|
|
24
|
+
/** The platform's own element for the frame (an `HTMLIFrameElement` on the web). */
|
|
25
|
+
readonly element: unknown;
|
|
26
|
+
setHeight(px: number): void;
|
|
27
|
+
setColorScheme(scheme: ColorScheme): void;
|
|
28
|
+
/**
|
|
29
|
+
* Whether the embedding page has transient user activation now (a gesture in the frame
|
|
30
|
+
* activates its ancestors), or undefined where the platform can't tell.
|
|
31
|
+
*/
|
|
32
|
+
userActivation(): boolean | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The frame loaded a document other than the one it was given: the content navigated it. The
|
|
35
|
+
* framework starts it again from its content and reports it.
|
|
36
|
+
*/
|
|
37
|
+
onNavigated(listener: () => void): () => void;
|
|
38
|
+
destroy(): void;
|
|
39
|
+
}
|
|
40
|
+
export interface PlatformHost<Container = HTMLElement> {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
/** Creates an isolated frame in `container`, its policy set before its document loads. */
|
|
43
|
+
createSurface(container: Container, spec: SurfaceSpec): Surface;
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PlatformHost } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The web platform: an `<iframe>` whose policy attributes are set before its document is given,
|
|
4
|
+
* talking over `postMessage`. Its first load is the document it was given; any later load is the
|
|
5
|
+
* content navigating its frame away (a navigation the embedding page's `frame-src` let through, or
|
|
6
|
+
* one to a document of the frame's own), which the framework answers by starting it again.
|
|
7
|
+
*/
|
|
8
|
+
export declare const webPlatform: PlatformHost;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { postMessageTransport } from "../transport/post-message.js";
|
|
2
|
+
/**
|
|
3
|
+
* The web platform: an `<iframe>` whose policy attributes are set before its document is given,
|
|
4
|
+
* talking over `postMessage`. Its first load is the document it was given; any later load is the
|
|
5
|
+
* content navigating its frame away (a navigation the embedding page's `frame-src` let through, or
|
|
6
|
+
* one to a document of the frame's own), which the framework answers by starting it again.
|
|
7
|
+
*/
|
|
8
|
+
export const webPlatform = {
|
|
9
|
+
name: "web",
|
|
10
|
+
createSurface(container, spec) {
|
|
11
|
+
// oxlint-disable-next-line react/iframe-missing-sandbox -- set from the compiled config, next
|
|
12
|
+
const frame = container.ownerDocument.createElement("iframe");
|
|
13
|
+
// Policy first: the sandbox and Permissions Policy must be in place before the document is.
|
|
14
|
+
frame.setAttribute("sandbox", spec.sandbox);
|
|
15
|
+
frame.setAttribute("allow", spec.allow);
|
|
16
|
+
frame.setAttribute("referrerpolicy", spec.referrerPolicy);
|
|
17
|
+
frame.setAttribute("loading", "eager");
|
|
18
|
+
frame.title = spec.title;
|
|
19
|
+
frame.dataset.portholeFrame = "";
|
|
20
|
+
Object.assign(frame.style, {
|
|
21
|
+
display: "block",
|
|
22
|
+
width: "100%",
|
|
23
|
+
border: "0",
|
|
24
|
+
background: "transparent",
|
|
25
|
+
colorScheme: spec.colorScheme,
|
|
26
|
+
height: `${Math.max(0, Math.round(spec.initialHeightPx))}px`,
|
|
27
|
+
});
|
|
28
|
+
const origin = spec.source.kind === "srcdoc" ? "null" : spec.source.origin;
|
|
29
|
+
if (spec.source.kind === "srcdoc") {
|
|
30
|
+
frame.srcdoc = spec.source.html;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
frame.src = spec.source.url;
|
|
34
|
+
}
|
|
35
|
+
const navigated = new Set();
|
|
36
|
+
let loads = 0;
|
|
37
|
+
frame.addEventListener("load", () => {
|
|
38
|
+
loads += 1;
|
|
39
|
+
if (loads > 1) {
|
|
40
|
+
for (const listener of navigated) {
|
|
41
|
+
listener();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
container.append(frame);
|
|
46
|
+
const view = container.ownerDocument.defaultView ?? window;
|
|
47
|
+
const transport = postMessageTransport(() => frame.contentWindow, origin, view);
|
|
48
|
+
return {
|
|
49
|
+
transport,
|
|
50
|
+
element: frame,
|
|
51
|
+
setHeight(px) {
|
|
52
|
+
frame.style.height = `${Math.max(0, Math.round(px))}px`;
|
|
53
|
+
},
|
|
54
|
+
setColorScheme(scheme) {
|
|
55
|
+
frame.style.colorScheme = scheme;
|
|
56
|
+
},
|
|
57
|
+
userActivation() {
|
|
58
|
+
const activation = view.navigator.userActivation;
|
|
59
|
+
return activation === undefined ? undefined : activation.isActive;
|
|
60
|
+
},
|
|
61
|
+
onNavigated(listener) {
|
|
62
|
+
navigated.add(listener);
|
|
63
|
+
return () => navigated.delete(listener);
|
|
64
|
+
},
|
|
65
|
+
destroy() {
|
|
66
|
+
navigated.clear();
|
|
67
|
+
transport.close();
|
|
68
|
+
frame.remove();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { CapabilityName } from "../config/capabilities.ts";
|
|
2
|
+
import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeValue } from "../config/schema.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The preset policy layer (P001): named configs with safe defaults. Every preset starts with
|
|
5
|
+
* egress closed and only the capabilities that carry nothing out of the frame (its size, its
|
|
6
|
+
* errors, both to the host only); a host declares the rest, and each gets its default gates
|
|
7
|
+
* unless the host names its own. A preset returns a validated config.
|
|
8
|
+
*/
|
|
9
|
+
/** What a host may change about a preset. Anything else is the preset's. */
|
|
10
|
+
export interface PresetOptions {
|
|
11
|
+
/** Capabilities to declare (merged over the preset's), or `false` to drop one the preset has. */
|
|
12
|
+
readonly capabilities?: {
|
|
13
|
+
readonly [N in CapabilityName]?: CapabilityConfig | false;
|
|
14
|
+
};
|
|
15
|
+
/** Origins to open, added to the preset's (closed) egress. */
|
|
16
|
+
readonly egress?: EgressConfig;
|
|
17
|
+
/** Theme defaults to change or variables to add, and the theme event's name. */
|
|
18
|
+
readonly theme?: {
|
|
19
|
+
readonly variables?: Readonly<Record<string, ThemeValue>>;
|
|
20
|
+
readonly event?: string;
|
|
21
|
+
};
|
|
22
|
+
readonly limits?: Partial<Limits>;
|
|
23
|
+
readonly layout?: Partial<LayoutConfig>;
|
|
24
|
+
readonly guide?: GuideConfig;
|
|
25
|
+
/** `opaque` (default) or `domain` for a served document (needs `contentOrigins`). */
|
|
26
|
+
readonly isolation?: Exclude<IsolationKind, "external">;
|
|
27
|
+
readonly contentOrigins?: readonly string[];
|
|
28
|
+
readonly embedder?: EmbedderConfig;
|
|
29
|
+
}
|
|
30
|
+
/** The url preset's options: where the app is served, and the embedding page's extra frames. */
|
|
31
|
+
export interface UrlPresetOptions {
|
|
32
|
+
readonly contentOrigins: readonly string[];
|
|
33
|
+
readonly limits?: Partial<Limits>;
|
|
34
|
+
readonly layout?: Partial<LayoutConfig>;
|
|
35
|
+
readonly embedder?: EmbedderConfig;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Library CDNs a host may open with one line (`egress: LIBRARY_CDNS`): read-only package caches
|
|
39
|
+
* for scripts, styles, fonts, images and data, plus Google Fonts. Not on by default: every preset
|
|
40
|
+
* starts with egress closed.
|
|
41
|
+
*/
|
|
42
|
+
export declare const LIBRARY_CDNS: EgressConfig;
|
|
43
|
+
export declare const presets: {
|
|
44
|
+
/**
|
|
45
|
+
* An HTML fragment or SVG an agent wrote, drawn inline (a card in a conversation): an opaque
|
|
46
|
+
* frame with the shell, streamed as it is written.
|
|
47
|
+
*/
|
|
48
|
+
readonly inlineWidget: (options?: PresetOptions) => PortholeConfig;
|
|
49
|
+
/**
|
|
50
|
+
* A whole HTML page: written into an opaque frame by the shell where the host has the page, or
|
|
51
|
+
* served on its own origin (`isolation: "domain"`). A document scrolls inside past a shorter cap.
|
|
52
|
+
*/
|
|
53
|
+
readonly artifact: (options?: PresetOptions) => PortholeConfig;
|
|
54
|
+
/**
|
|
55
|
+
* A standard MCP Apps view, through an MCP Apps adapter. The view's own `_meta.ui.csp` is
|
|
56
|
+
* mapped into `egress` by that adapter; the host declares the capabilities it serves.
|
|
57
|
+
*/
|
|
58
|
+
readonly mcpApp: (options?: PresetOptions) => PortholeConfig;
|
|
59
|
+
/**
|
|
60
|
+
* A running app by URL (a dev server's preview): frame, isolation and access auth only. No
|
|
61
|
+
* shell, no capabilities, no egress control: the app is someone else's, on its own origin.
|
|
62
|
+
*/
|
|
63
|
+
readonly url: (options: UrlPresetOptions) => PortholeConfig;
|
|
64
|
+
};
|
|
65
|
+
export type PresetName = keyof typeof presets;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { validateConfig } from "../config/validate.js";
|
|
2
|
+
import { DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES } from "../theme/variables.js";
|
|
3
|
+
/**
|
|
4
|
+
* Library CDNs a host may open with one line (`egress: LIBRARY_CDNS`): read-only package caches
|
|
5
|
+
* for scripts, styles, fonts, images and data, plus Google Fonts. Not on by default: every preset
|
|
6
|
+
* starts with egress closed.
|
|
7
|
+
*/
|
|
8
|
+
export const LIBRARY_CDNS = {
|
|
9
|
+
script: [
|
|
10
|
+
"https://cdnjs.cloudflare.com",
|
|
11
|
+
"https://cdn.jsdelivr.net",
|
|
12
|
+
"https://unpkg.com",
|
|
13
|
+
"https://esm.sh",
|
|
14
|
+
],
|
|
15
|
+
style: [
|
|
16
|
+
"https://cdnjs.cloudflare.com",
|
|
17
|
+
"https://cdn.jsdelivr.net",
|
|
18
|
+
"https://unpkg.com",
|
|
19
|
+
"https://esm.sh",
|
|
20
|
+
"https://fonts.googleapis.com",
|
|
21
|
+
],
|
|
22
|
+
font: [
|
|
23
|
+
"https://fonts.gstatic.com",
|
|
24
|
+
"https://cdnjs.cloudflare.com",
|
|
25
|
+
"https://cdn.jsdelivr.net",
|
|
26
|
+
"https://unpkg.com",
|
|
27
|
+
"https://esm.sh",
|
|
28
|
+
],
|
|
29
|
+
img: [
|
|
30
|
+
"https://cdnjs.cloudflare.com",
|
|
31
|
+
"https://cdn.jsdelivr.net",
|
|
32
|
+
"https://unpkg.com",
|
|
33
|
+
"https://esm.sh",
|
|
34
|
+
],
|
|
35
|
+
connect: [
|
|
36
|
+
"https://cdnjs.cloudflare.com",
|
|
37
|
+
"https://cdn.jsdelivr.net",
|
|
38
|
+
"https://unpkg.com",
|
|
39
|
+
"https://esm.sh",
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
const BASE_LIMITS = {
|
|
43
|
+
maxHeightPx: 2_000,
|
|
44
|
+
minHeightPx: 24,
|
|
45
|
+
maxBytes: 200_000,
|
|
46
|
+
maxLiveFrames: 8,
|
|
47
|
+
declinesBeforeMute: 3,
|
|
48
|
+
maxPromptChars: 8_000,
|
|
49
|
+
maxErrorReports: 5,
|
|
50
|
+
};
|
|
51
|
+
const BASE_LAYOUT = { minWidthPx: 280, narrowWidthPx: 300, wideWidthPx: 750 };
|
|
52
|
+
/** Only what reports to the host and carries nothing out: the frame's size and its errors. */
|
|
53
|
+
const BASE_CAPABILITIES = { resize: {}, reportError: {} };
|
|
54
|
+
const mergeEgress = (base, extra) => {
|
|
55
|
+
const kinds = ["connect", "script", "style", "img", "font", "media"];
|
|
56
|
+
const merged = {};
|
|
57
|
+
for (const kind of kinds) {
|
|
58
|
+
const origins = [...new Set([...(base[kind] ?? []), ...(extra?.[kind] ?? [])])];
|
|
59
|
+
if (origins.length > 0) {
|
|
60
|
+
merged[kind] = origins;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return merged;
|
|
64
|
+
};
|
|
65
|
+
function build(mode, defaults, options) {
|
|
66
|
+
const capabilities = {
|
|
67
|
+
...BASE_CAPABILITIES,
|
|
68
|
+
};
|
|
69
|
+
for (const [name, value] of Object.entries(options.capabilities ?? {})) {
|
|
70
|
+
if (value === false) {
|
|
71
|
+
delete capabilities[name];
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
capabilities[name] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return validateConfig({
|
|
78
|
+
mode,
|
|
79
|
+
isolation: options.isolation ?? "opaque",
|
|
80
|
+
capabilities,
|
|
81
|
+
egress: mergeEgress({}, options.egress),
|
|
82
|
+
theme: {
|
|
83
|
+
variables: { ...DEFAULT_THEME_VARIABLES, ...options.theme?.variables },
|
|
84
|
+
event: options.theme?.event ?? DEFAULT_THEME_EVENT,
|
|
85
|
+
},
|
|
86
|
+
limits: { ...BASE_LIMITS, ...defaults.limits, ...options.limits },
|
|
87
|
+
layout: { ...BASE_LAYOUT, ...options.layout },
|
|
88
|
+
guide: options.guide ?? {},
|
|
89
|
+
contentOrigins: options.contentOrigins ?? [],
|
|
90
|
+
embedder: options.embedder ?? {},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export const presets = {
|
|
94
|
+
/**
|
|
95
|
+
* An HTML fragment or SVG an agent wrote, drawn inline (a card in a conversation): an opaque
|
|
96
|
+
* frame with the shell, streamed as it is written.
|
|
97
|
+
*/
|
|
98
|
+
inlineWidget: (options = {}) => build("inline", {}, options),
|
|
99
|
+
/**
|
|
100
|
+
* A whole HTML page: written into an opaque frame by the shell where the host has the page, or
|
|
101
|
+
* served on its own origin (`isolation: "domain"`). A document scrolls inside past a shorter cap.
|
|
102
|
+
*/
|
|
103
|
+
artifact: (options = {}) => build("artifact", { limits: { maxHeightPx: 720, minHeightPx: 120 } }, options),
|
|
104
|
+
/**
|
|
105
|
+
* A standard MCP Apps view, through an MCP Apps adapter. The view's own `_meta.ui.csp` is
|
|
106
|
+
* mapped into `egress` by that adapter; the host declares the capabilities it serves.
|
|
107
|
+
*/
|
|
108
|
+
mcpApp: (options = {}) => build("mcp-app", {}, options),
|
|
109
|
+
/**
|
|
110
|
+
* A running app by URL (a dev server's preview): frame, isolation and access auth only. No
|
|
111
|
+
* shell, no capabilities, no egress control: the app is someone else's, on its own origin.
|
|
112
|
+
*/
|
|
113
|
+
url: (options) => validateConfig({
|
|
114
|
+
mode: "url",
|
|
115
|
+
isolation: "external",
|
|
116
|
+
capabilities: {},
|
|
117
|
+
egress: {},
|
|
118
|
+
theme: { variables: {}, event: DEFAULT_THEME_EVENT },
|
|
119
|
+
limits: { ...BASE_LIMITS, maxHeightPx: 4_000, minHeightPx: 120, ...options.limits },
|
|
120
|
+
layout: { ...BASE_LAYOUT, ...options.layout },
|
|
121
|
+
guide: {},
|
|
122
|
+
contentOrigins: options.contentOrigins,
|
|
123
|
+
embedder: options.embedder ?? {},
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { HostFunctionBridge } from "../bridge/types.ts";
|
|
2
|
+
import type { CompiledConfig } from "../compile/compile.ts";
|
|
3
|
+
import type { Transport } from "../transport/types.ts";
|
|
4
|
+
import type { HostContext } from "./mcp-apps.ts";
|
|
5
|
+
/**
|
|
6
|
+
* The protocol-adapter seam (P004): the only layer that knows a wire protocol's method names and
|
|
7
|
+
* shapes. The framework speaks capabilities and lifecycle; an adapter translates.
|
|
8
|
+
*
|
|
9
|
+
* - Frame → host: the adapter validates each message (dropping what it doesn't recognize, and
|
|
10
|
+
* answering an unknown request with "method not found"), turns every capability use into
|
|
11
|
+
* `bridge.call(...)` (including sizes and errors, which the bridge turns into the framework's
|
|
12
|
+
* own events), answers the handshake with `hostContext()`, and reports lifecycle with `emit`.
|
|
13
|
+
* - Host → frame: the framework calls the connection; the adapter encodes and sends.
|
|
14
|
+
*
|
|
15
|
+
* The bridge alone decides whether a call runs (declared, valid, gated); an adapter never calls a
|
|
16
|
+
* host handler itself.
|
|
17
|
+
*/
|
|
18
|
+
/** What the framework gives an adapter for one display. */
|
|
19
|
+
export interface AdapterSession {
|
|
20
|
+
readonly display: {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly title: string;
|
|
23
|
+
};
|
|
24
|
+
readonly compiled: CompiledConfig;
|
|
25
|
+
readonly transport: Transport;
|
|
26
|
+
readonly bridge: HostFunctionBridge;
|
|
27
|
+
readonly hostInfo: {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly version: string;
|
|
30
|
+
};
|
|
31
|
+
/** The host context as of now: theme, its variables, display mode, sizes, locale. */
|
|
32
|
+
hostContext(): HostContext;
|
|
33
|
+
/** Lifecycle the framework acts on. */
|
|
34
|
+
emit(event: AdapterEvent): void;
|
|
35
|
+
/**
|
|
36
|
+
* The mount's whole document, when the host passed one (an MCP Apps view): for an adapter that
|
|
37
|
+
* delivers it itself, as through a sandbox proxy. In opaque isolation the framework has already
|
|
38
|
+
* put it in the frame, hardened.
|
|
39
|
+
*/
|
|
40
|
+
readonly document?: string;
|
|
41
|
+
}
|
|
42
|
+
export type AdapterEvent =
|
|
43
|
+
/** The view finished its handshake; content may be sent. */
|
|
44
|
+
{
|
|
45
|
+
readonly type: "initialized";
|
|
46
|
+
}
|
|
47
|
+
/** The view asked to be closed. */
|
|
48
|
+
| {
|
|
49
|
+
readonly type: "teardown-requested";
|
|
50
|
+
};
|
|
51
|
+
/** What the framework drives on every adapter. Adapters add their protocol's own operations. */
|
|
52
|
+
export interface AdapterConnection {
|
|
53
|
+
/** The host context changed (a theme flip, a new width): tell the view. */
|
|
54
|
+
hostContextChanged(context: HostContext): void;
|
|
55
|
+
/** Asks the view to clean up before it is removed; resolves when it answered or gave up. */
|
|
56
|
+
teardown(): Promise<void>;
|
|
57
|
+
/** Stops listening. The framework closes the transport. */
|
|
58
|
+
close(): void;
|
|
59
|
+
}
|
|
60
|
+
export interface ProtocolAdapter<C extends AdapterConnection = AdapterConnection> {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
connect(session: AdapterSession): C;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The core adapter's connection: the MCP Apps subset the shell speaks (inline widgets, artifact
|
|
66
|
+
* pages). Content goes as `tool-input-partial` while it is written, then `tool-input` once.
|
|
67
|
+
*/
|
|
68
|
+
export interface ShellConnection extends AdapterConnection {
|
|
69
|
+
/** The content so far (`partial`, drawn inert, any number of times), then whole, once. */
|
|
70
|
+
setContent(html: string, options?: {
|
|
71
|
+
readonly partial?: boolean;
|
|
72
|
+
}): void;
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|