@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.
Files changed (150) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +184 -0
  4. package/dist/bridge/types.d.ts +67 -0
  5. package/dist/bridge/types.js +1 -0
  6. package/dist/compile/compile.d.ts +35 -0
  7. package/dist/compile/compile.js +30 -0
  8. package/dist/compile/policy.d.ts +55 -0
  9. package/dist/compile/policy.js +106 -0
  10. package/dist/config/capabilities.d.ts +154 -0
  11. package/dist/config/capabilities.js +63 -0
  12. package/dist/config/schema.d.ts +131 -0
  13. package/dist/config/schema.js +1 -0
  14. package/dist/config/validate.d.ts +20 -0
  15. package/dist/config/validate.js +230 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +376 -0
  18. package/dist/conformance/index.d.ts +95 -0
  19. package/dist/conformance/index.js +4 -0
  20. package/dist/conformance/kit.d.ts +42 -0
  21. package/dist/conformance/kit.js +24 -0
  22. package/dist/conformance/oracles.d.ts +35 -0
  23. package/dist/conformance/oracles.js +77 -0
  24. package/dist/conformance/probes.d.ts +9 -0
  25. package/dist/conformance/probes.js +865 -0
  26. package/dist/conformance/served.d.ts +68 -0
  27. package/dist/conformance/served.js +311 -0
  28. package/dist/guide/examples.d.ts +70 -0
  29. package/dist/guide/examples.js +69 -0
  30. package/dist/guide/guide.d.ts +16 -0
  31. package/dist/guide/guide.js +261 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +248 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +11 -0
  36. package/dist/host/types.d.ts +95 -0
  37. package/dist/host/types.js +1 -0
  38. package/dist/index.d.ts +23 -0
  39. package/dist/index.js +19 -0
  40. package/dist/isolation/served.d.ts +32 -0
  41. package/dist/isolation/served.js +97 -0
  42. package/dist/isolation/types.d.ts +87 -0
  43. package/dist/isolation/types.js +1 -0
  44. package/dist/mcp/adapter.d.ts +43 -0
  45. package/dist/mcp/adapter.js +327 -0
  46. package/dist/mcp/index.d.ts +27 -0
  47. package/dist/mcp/index.js +25 -0
  48. package/dist/mcp/proxy.d.ts +25 -0
  49. package/dist/mcp/proxy.js +106 -0
  50. package/dist/mcp/view.d.ts +31 -0
  51. package/dist/mcp/view.js +72 -0
  52. package/dist/platform/types.d.ts +44 -0
  53. package/dist/platform/types.js +1 -0
  54. package/dist/platform/web.d.ts +8 -0
  55. package/dist/platform/web.js +72 -0
  56. package/dist/presets/presets.d.ts +65 -0
  57. package/dist/presets/presets.js +125 -0
  58. package/dist/protocol/adapter.d.ts +73 -0
  59. package/dist/protocol/adapter.js +1 -0
  60. package/dist/protocol/jsonrpc.d.ts +43 -0
  61. package/dist/protocol/jsonrpc.js +50 -0
  62. package/dist/protocol/mcp-apps.d.ts +79 -0
  63. package/dist/protocol/mcp-apps.js +99 -0
  64. package/dist/protocol/shell-adapter.d.ts +5 -0
  65. package/dist/protocol/shell-adapter.js +228 -0
  66. package/dist/react/dialog.d.ts +20 -0
  67. package/dist/react/dialog.js +98 -0
  68. package/dist/react/frame.d.ts +75 -0
  69. package/dist/react/frame.js +221 -0
  70. package/dist/react/index.d.ts +13 -0
  71. package/dist/react/index.js +13 -0
  72. package/dist/react/live.d.ts +24 -0
  73. package/dist/react/live.js +57 -0
  74. package/dist/server/index.d.ts +27 -0
  75. package/dist/server/index.js +26 -0
  76. package/dist/server/keys.d.ts +19 -0
  77. package/dist/server/keys.js +78 -0
  78. package/dist/server/pages.d.ts +6 -0
  79. package/dist/server/pages.js +20 -0
  80. package/dist/server/proxy.d.ts +21 -0
  81. package/dist/server/proxy.js +107 -0
  82. package/dist/server/server.d.ts +7 -0
  83. package/dist/server/server.js +518 -0
  84. package/dist/server/tickets.d.ts +8 -0
  85. package/dist/server/tickets.js +28 -0
  86. package/dist/server/types.d.ts +97 -0
  87. package/dist/server/types.js +1 -0
  88. package/dist/shell/document.d.ts +28 -0
  89. package/dist/shell/document.js +84 -0
  90. package/dist/shell/plan.d.ts +35 -0
  91. package/dist/shell/plan.js +34 -0
  92. package/dist/shell/runtime.d.ts +28 -0
  93. package/dist/shell/runtime.js +832 -0
  94. package/dist/theme/variables.d.ts +73 -0
  95. package/dist/theme/variables.js +171 -0
  96. package/dist/transport/post-message.d.ts +9 -0
  97. package/dist/transport/post-message.js +38 -0
  98. package/dist/transport/types.d.ts +15 -0
  99. package/dist/transport/types.js +1 -0
  100. package/package.json +68 -3
  101. package/src/bridge/bridge.ts +256 -0
  102. package/src/bridge/types.ts +77 -0
  103. package/src/compile/compile.ts +68 -0
  104. package/src/compile/policy.ts +133 -0
  105. package/src/config/capabilities.ts +138 -0
  106. package/src/config/schema.ts +140 -0
  107. package/src/config/validate.ts +284 -0
  108. package/src/conformance/behavior.ts +430 -0
  109. package/src/conformance/index.ts +102 -0
  110. package/src/conformance/kit.ts +63 -0
  111. package/src/conformance/oracles.ts +126 -0
  112. package/src/conformance/probes.ts +1093 -0
  113. package/src/conformance/served.ts +472 -0
  114. package/src/guide/examples.ts +74 -0
  115. package/src/guide/guide.ts +424 -0
  116. package/src/host/create-host.ts +290 -0
  117. package/src/host/locate.ts +20 -0
  118. package/src/host/types.ts +95 -0
  119. package/src/index.ts +118 -0
  120. package/src/isolation/served.ts +127 -0
  121. package/src/isolation/types.ts +77 -0
  122. package/src/mcp/adapter.ts +441 -0
  123. package/src/mcp/index.ts +50 -0
  124. package/src/mcp/proxy.ts +117 -0
  125. package/src/mcp/view.ts +97 -0
  126. package/src/platform/types.ts +48 -0
  127. package/src/platform/web.ts +78 -0
  128. package/src/presets/presets.ts +193 -0
  129. package/src/protocol/adapter.ts +67 -0
  130. package/src/protocol/jsonrpc.ts +83 -0
  131. package/src/protocol/mcp-apps.ts +138 -0
  132. package/src/protocol/shell-adapter.ts +261 -0
  133. package/src/react/dialog.tsx +158 -0
  134. package/src/react/frame.tsx +422 -0
  135. package/src/react/index.ts +20 -0
  136. package/src/react/live.ts +76 -0
  137. package/src/server/index.ts +37 -0
  138. package/src/server/keys.ts +100 -0
  139. package/src/server/pages.ts +31 -0
  140. package/src/server/proxy.ts +131 -0
  141. package/src/server/server.ts +617 -0
  142. package/src/server/tickets.ts +30 -0
  143. package/src/server/types.ts +104 -0
  144. package/src/shell/document.ts +120 -0
  145. package/src/shell/plan.ts +66 -0
  146. package/src/shell/runtime.ts +832 -0
  147. package/src/theme/variables.ts +190 -0
  148. package/src/transport/post-message.ts +45 -0
  149. package/src/transport/types.ts +16 -0
  150. package/README.md +0 -3
@@ -0,0 +1,832 @@
1
+ /**
2
+ * The shell's runtime: the code that runs first in the frame, before any content. Plain ES2020,
3
+ * no imports and no build step, so every engine (a browser frame now, a native web view later)
4
+ * runs it as it is. `__PLAN__` is replaced with the frame's plan (`ShellPlan` plus the origins it
5
+ * takes messages from, and a document to render, if any).
6
+ *
7
+ * Before any content runs, it:
8
+ * 1. stands in for storage an opaque origin lacks (a map for the frame's life);
9
+ * 2. removes every WebRTC constructor, non-configurably: a peer connection reaches the network
10
+ * outside connect-src (ICE gathering), and not every engine honors the CSP's `webrtc 'block'`;
11
+ * 3. removes every frame-like element content adds, wherever it adds it (the document, any shadow
12
+ * root), since a nested frame runs in a fresh realm where step 2 never happened (and not every
13
+ * engine enforces `frame-src 'none'` for `srcdoc`/`about:blank` children); and the resource
14
+ * hints and refreshes a CSP doesn't govern;
15
+ * 4. closes the parser entry points that could build a shadow root step 3 can't see (declarative
16
+ * shadow DOM through `setHTMLUnsafe`, `parseHTMLUnsafe`, `document.write`).
17
+ * A whole document (an artifact page, an MCP Apps view) is never written in: it is parsed inert,
18
+ * imported, and its scripts run in order, as a fragment's are.
19
+ *
20
+ * Then, when it speaks for the content (`protocol: "shell"`), it asks its host `ui/initialize`,
21
+ * applies the theme the host answers, draws the content it is sent (inert while partial, run once
22
+ * when whole), reports its size and errors, sends link clicks out through `openLink`, and defines
23
+ * a global for each declared capability that has one.
24
+ *
25
+ * Nothing secret is ever in the frame: everything it says, the host treats as the content's own
26
+ * words. It takes messages only from its parent, and only from the plan's origins.
27
+ */
28
+ export const SHELL_RUNTIME = String.raw`(() => {
29
+ "use strict";
30
+ const plan = __PLAN__;
31
+ const declared = (name) => Object.prototype.hasOwnProperty.call(plan.capabilities, name);
32
+ // Fixed in place: content can neither restore what is removed nor replace what is wrapped.
33
+ const lock = (target, name, value) => {
34
+ try {
35
+ Object.defineProperty(target, name, { configurable: false, writable: false, value });
36
+ } catch (_) {}
37
+ };
38
+
39
+ if (plan.storage === "memory") {
40
+ // An opaque origin has no storage: a map stands in, for as long as the frame lives.
41
+ const store = () => {
42
+ const items = new Map();
43
+ return {
44
+ getItem: (key) => (items.has(String(key)) ? items.get(String(key)) : null),
45
+ setItem: (key, value) => void items.set(String(key), String(value)),
46
+ removeItem: (key) => void items.delete(String(key)),
47
+ clear: () => items.clear(),
48
+ key: (index) => Array.from(items.keys())[index] ?? null,
49
+ get length() {
50
+ return items.size;
51
+ },
52
+ };
53
+ };
54
+ for (const name of ["localStorage", "sessionStorage"]) {
55
+ try {
56
+ Object.defineProperty(window, name, { configurable: true, value: store() });
57
+ } catch (_) {}
58
+ }
59
+ }
60
+
61
+ // WebRTC leaves the frame outside connect-src. Only RTCPeerConnection (and its prefixed alias)
62
+ // opens a connection; the rest are value types, removed too so nothing is assembled from them.
63
+ for (const name of Object.getOwnPropertyNames(window)) {
64
+ if (/^(RTC|webkitRTC)/.test(name)) {
65
+ lock(window, name, undefined);
66
+ }
67
+ }
68
+
69
+ // No nested frames (each would be a fresh realm with WebRTC intact), no resource hints and no
70
+ // refreshes (they reach the network outside the CSP's fetch directives). Removed as they are
71
+ // added: a mutation observer's microtask runs before a child frame's document loads.
72
+ const FRAMES = "iframe,frame,frameset,object,embed,portal,fencedframe";
73
+ const HINTS = 'link[rel~="dns-prefetch" i],link[rel~="preconnect" i],link[rel~="prefetch" i],link[rel~="prerender" i],script[type="speculationrules" i],meta[http-equiv="refresh" i],meta[http-equiv="x-dns-prefetch-control" i]';
74
+ const GUARDED = FRAMES + "," + HINTS;
75
+ const guardedRoots = new WeakSet();
76
+ let guardRoot = null;
77
+ const strip = (node) => {
78
+ if (node === null || (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11)) {
79
+ return;
80
+ }
81
+ if (node.nodeType === 1 && node.matches(GUARDED)) {
82
+ node.remove();
83
+ return;
84
+ }
85
+ for (const element of Array.from(node.querySelectorAll(GUARDED))) {
86
+ element.remove();
87
+ }
88
+ // Open shadow roots made without attachShadow (declarative ones the parser built elsewhere).
89
+ if (node.nodeType === 1 && node.shadowRoot) {
90
+ guardRoot(node.shadowRoot);
91
+ }
92
+ for (const element of Array.from(node.querySelectorAll("*"))) {
93
+ if (element.shadowRoot) {
94
+ guardRoot(element.shadowRoot);
95
+ }
96
+ }
97
+ };
98
+ const frameGuard = new MutationObserver((records) => {
99
+ for (const record of records) {
100
+ for (const node of Array.from(record.addedNodes)) {
101
+ strip(node);
102
+ }
103
+ }
104
+ });
105
+ guardRoot = (root) => {
106
+ if (guardedRoots.has(root)) {
107
+ return;
108
+ }
109
+ guardedRoots.add(root);
110
+ strip(root);
111
+ frameGuard.observe(root, { childList: true, subtree: true });
112
+ };
113
+ guardRoot(document);
114
+
115
+ // Every shadow root content makes, open or closed, is guarded as it is made.
116
+ const attachShadow = Element.prototype.attachShadow;
117
+ lock(Element.prototype, "attachShadow", function (init) {
118
+ const root = attachShadow.call(this, init);
119
+ guardRoot(root);
120
+ return root;
121
+ });
122
+
123
+ // The parser builds declarative shadow roots (template shadowrootmode) without attachShadow, and
124
+ // a closed one is out of the guard's reach. Fragment parsing doesn't build them, so the entry
125
+ // points that would are given fragment parsing's behavior, and a document can't be rewritten.
126
+ const parseInert = (html) => {
127
+ const template = document.createElement("template");
128
+ template.innerHTML = String(html);
129
+ return template.content;
130
+ };
131
+ for (const proto of [Element.prototype, typeof ShadowRoot === "function" ? ShadowRoot.prototype : null]) {
132
+ if (proto !== null && "setHTMLUnsafe" in proto) {
133
+ lock(proto, "setHTMLUnsafe", function (html) {
134
+ this.innerHTML = String(html);
135
+ });
136
+ }
137
+ }
138
+ if (typeof Document.parseHTMLUnsafe === "function") {
139
+ lock(Document, "parseHTMLUnsafe", (html) => new DOMParser().parseFromString(String(html), "text/html"));
140
+ }
141
+ const noRewrite = function () {
142
+ throw new Error("document.open and document.write aren't available here: build the page with DOM methods");
143
+ };
144
+ for (const name of ["open", "write", "writeln"]) {
145
+ lock(Document.prototype, name, noRewrite);
146
+ }
147
+
148
+ // Some engines act on a hint the moment its element is connected or changed (WebKit opens a
149
+ // preconnect then, outside the CSP), before the observer's microtask. So every way to connect a
150
+ // node drops guarded elements first, synchronously, and a connected link or meta can't be
151
+ // changed into one. The observer stays as the backstop for anything these miss.
152
+ const admit = (node) => {
153
+ if (node === null || typeof node !== "object" || typeof node.nodeType !== "number") {
154
+ return true;
155
+ }
156
+ if (node.nodeType === 1 && node.matches(GUARDED)) {
157
+ return false;
158
+ }
159
+ strip(node);
160
+ return true;
161
+ };
162
+ const admitted = (args) => args.map((arg) => (admit(arg) ? arg : document.createTextNode("")));
163
+ const wrapInsert = (proto, name, positions) => {
164
+ const original = proto && proto[name];
165
+ if (typeof original !== "function") {
166
+ return;
167
+ }
168
+ lock(proto, name, function (...args) {
169
+ const checked = positions === "all" ? admitted(args) : args.map((arg, index) => (positions.indexOf(index) !== -1 && !admit(arg) ? document.createTextNode("") : arg));
170
+ return original.apply(this, checked);
171
+ });
172
+ };
173
+ for (const proto of [Node.prototype]) {
174
+ wrapInsert(proto, "appendChild", [0]);
175
+ wrapInsert(proto, "insertBefore", [0]);
176
+ wrapInsert(proto, "replaceChild", [0]);
177
+ }
178
+ for (const proto of [Element.prototype, Document.prototype, DocumentFragment.prototype]) {
179
+ for (const name of ["append", "prepend", "replaceChildren"]) {
180
+ wrapInsert(proto, name, "all");
181
+ }
182
+ }
183
+ for (const proto of [Element.prototype, CharacterData.prototype, DocumentType.prototype]) {
184
+ for (const name of ["before", "after", "replaceWith"]) {
185
+ wrapInsert(proto, name, "all");
186
+ }
187
+ }
188
+ wrapInsert(Element.prototype, "insertAdjacentElement", [1]);
189
+ wrapInsert(Range.prototype, "insertNode", [0]);
190
+ wrapInsert(Range.prototype, "surroundContents", [0]);
191
+
192
+ // Markup parsed in place: parsed first in a detached element of the same kind (so it parses as it
193
+ // would in place, and nothing in it is connected yet), guarded, then moved in.
194
+ const accessor = (proto, name) => (proto ? Object.getOwnPropertyDescriptor(proto, name) : undefined);
195
+ const contextFor = (element) => {
196
+ if (element === null || element.nodeType !== 1) {
197
+ return document.createElement("div");
198
+ }
199
+ // A fresh element of the same kind; a custom element's own class is not run again.
200
+ const name = element.localName.indexOf("-") === -1 ? element.localName : "div";
201
+ return document.createElementNS(element.namespaceURI, name);
202
+ };
203
+ const innerHTMLs = [accessor(Element.prototype, "innerHTML"), accessor(typeof ShadowRoot === "function" ? ShadowRoot.prototype : null, "innerHTML")];
204
+ const setInner = (innerHTMLs[0] || innerHTMLs[1]).set;
205
+ const parseIn = (contextElement, html) => {
206
+ const box = contextFor(contextElement);
207
+ setInner.call(box, String(html));
208
+ strip(box);
209
+ return Array.from(box.childNodes);
210
+ };
211
+ [Element.prototype, typeof ShadowRoot === "function" ? ShadowRoot.prototype : null].forEach((proto, index) => {
212
+ const descriptor = innerHTMLs[index];
213
+ if (!proto || !descriptor || !descriptor.set) {
214
+ return;
215
+ }
216
+ Object.defineProperty(proto, "innerHTML", {
217
+ configurable: false,
218
+ enumerable: descriptor.enumerable,
219
+ get: descriptor.get,
220
+ set(html) {
221
+ if (this.nodeType === 1 && this.localName === "template") {
222
+ descriptor.set.call(this, html);
223
+ return;
224
+ }
225
+ this.replaceChildren(...parseIn(this.nodeType === 11 ? this.host || null : this, html));
226
+ },
227
+ });
228
+ });
229
+ const outer = accessor(Element.prototype, "outerHTML");
230
+ if (outer && outer.set) {
231
+ Object.defineProperty(Element.prototype, "outerHTML", {
232
+ configurable: false,
233
+ enumerable: outer.enumerable,
234
+ get: outer.get,
235
+ set(html) {
236
+ const parent = this.parentNode;
237
+ if (parent === null || parent.nodeType === 9) {
238
+ outer.set.call(this, html);
239
+ return;
240
+ }
241
+ this.replaceWith(...parseIn(parent.nodeType === 1 ? parent : null, html));
242
+ },
243
+ });
244
+ }
245
+ const insertAdjacentHTML = Element.prototype.insertAdjacentHTML;
246
+ lock(Element.prototype, "insertAdjacentHTML", function (position, html) {
247
+ insertAdjacentHTML.call(this, position, "");
248
+ const where = String(position).toLowerCase();
249
+ const outside = where === "beforebegin" || where === "afterend";
250
+ const nodes = parseIn(outside ? this.parentElement : this, html);
251
+ if (where === "beforebegin") {
252
+ this.before(...nodes);
253
+ } else if (where === "afterbegin") {
254
+ this.prepend(...nodes);
255
+ } else if (where === "beforeend") {
256
+ this.append(...nodes);
257
+ } else {
258
+ this.after(...nodes);
259
+ }
260
+ });
261
+ if (typeof Element.prototype.setHTML === "function") {
262
+ lock(Element.prototype, "setHTML", function (html) {
263
+ this.innerHTML = String(html);
264
+ });
265
+ }
266
+ const execCommand = Document.prototype.execCommand;
267
+ if (typeof execCommand === "function") {
268
+ lock(Document.prototype, "execCommand", function (command, ...rest) {
269
+ return String(command).toLowerCase() === "inserthtml" ? false : execCommand.call(this, command, ...rest);
270
+ });
271
+ }
272
+
273
+ // A connected link or meta can't become a guarded one: the change is tried on a detached copy
274
+ // first, and if the copy would be guarded, the element leaves the document before it changes.
275
+ const watched = (element) => element && element.nodeType === 1 && (element.localName === "link" || element.localName === "meta");
276
+ const beforeChange = (element, change) => {
277
+ if (!watched(element) || !element.isConnected) {
278
+ return;
279
+ }
280
+ const copy = element.cloneNode(false);
281
+ try {
282
+ change(copy);
283
+ } catch (_) {
284
+ return;
285
+ }
286
+ if (copy.matches(GUARDED)) {
287
+ element.remove();
288
+ }
289
+ };
290
+ const guardMethod = (proto, name, change) => {
291
+ const original = proto && proto[name];
292
+ if (typeof original !== "function") {
293
+ return;
294
+ }
295
+ lock(proto, name, function (...args) {
296
+ beforeChange(this, (copy) => change(copy, args, original));
297
+ return original.apply(this, args);
298
+ });
299
+ };
300
+ for (const name of ["setAttribute", "setAttributeNS", "toggleAttribute", "removeAttribute", "removeAttributeNS"]) {
301
+ guardMethod(Element.prototype, name, (copy, args, original) => original.apply(copy, args));
302
+ }
303
+ for (const name of ["setAttributeNode", "setAttributeNodeNS"]) {
304
+ guardMethod(Element.prototype, name, (copy, args) => copy.setAttributeNS(args[0].namespaceURI, args[0].name, args[0].value));
305
+ }
306
+ for (const [proto, name] of [[Attr.prototype, "value"], [Node.prototype, "nodeValue"], [Node.prototype, "textContent"]]) {
307
+ const descriptor = accessor(proto, name);
308
+ if (!descriptor || !descriptor.set) {
309
+ continue;
310
+ }
311
+ Object.defineProperty(proto, name, {
312
+ configurable: false,
313
+ enumerable: descriptor.enumerable,
314
+ get: descriptor.get,
315
+ set(value) {
316
+ if (this.nodeType === 2 && this.ownerElement) {
317
+ const attribute = this;
318
+ beforeChange(attribute.ownerElement, (copy) => copy.setAttributeNS(attribute.namespaceURI, attribute.name, String(value)));
319
+ }
320
+ descriptor.set.call(this, value);
321
+ },
322
+ });
323
+ }
324
+ // Attribute maps and token lists don't name their element: remember it when content gets one.
325
+ const owners = new WeakMap();
326
+ const remember = (proto, name) => {
327
+ const descriptor = accessor(proto, name);
328
+ if (!descriptor || !descriptor.get) {
329
+ return;
330
+ }
331
+ Object.defineProperty(proto, name, {
332
+ configurable: false,
333
+ enumerable: descriptor.enumerable,
334
+ get() {
335
+ const value = descriptor.get.call(this);
336
+ if (value !== null && typeof value === "object") {
337
+ owners.set(value, this);
338
+ }
339
+ return value;
340
+ },
341
+ // relList = "…" forwards to the list's value, natively: guarded here instead.
342
+ set: descriptor.set
343
+ ? function (value) {
344
+ beforeChange(this, (copy) => descriptor.set.call(copy, value));
345
+ descriptor.set.call(this, value);
346
+ }
347
+ : undefined,
348
+ });
349
+ };
350
+ remember(Element.prototype, "attributes");
351
+ for (const proto of [
352
+ typeof HTMLLinkElement === "function" ? HTMLLinkElement.prototype : null,
353
+ typeof HTMLMetaElement === "function" ? HTMLMetaElement.prototype : null,
354
+ ]) {
355
+ remember(proto, "relList");
356
+ for (const name of Object.getOwnPropertyNames(proto || {})) {
357
+ const descriptor = accessor(proto, name);
358
+ if (descriptor && descriptor.set && name !== "relList") {
359
+ Object.defineProperty(proto, name, {
360
+ configurable: false,
361
+ enumerable: descriptor.enumerable,
362
+ get: descriptor.get,
363
+ set(value) {
364
+ beforeChange(this, (copy) => descriptor.set.call(copy, value));
365
+ descriptor.set.call(this, value);
366
+ },
367
+ });
368
+ }
369
+ }
370
+ }
371
+ for (const name of ["setNamedItem", "setNamedItemNS", "removeNamedItem", "removeNamedItemNS"]) {
372
+ const original = NamedNodeMap.prototype[name];
373
+ if (typeof original !== "function") {
374
+ continue;
375
+ }
376
+ lock(NamedNodeMap.prototype, name, function (...args) {
377
+ beforeChange(owners.get(this), (copy) => {
378
+ if (name.indexOf("set") === 0) {
379
+ copy.setAttributeNS(args[0].namespaceURI, args[0].name, args[0].value);
380
+ } else {
381
+ copy.removeAttribute(args[args.length - 1]);
382
+ }
383
+ });
384
+ return original.apply(this, args);
385
+ });
386
+ }
387
+ const tokens = typeof DOMTokenList === "function" ? DOMTokenList.prototype : null;
388
+ for (const name of ["add", "remove", "replace", "toggle"]) {
389
+ const original = tokens && tokens[name];
390
+ if (typeof original === "function") {
391
+ lock(tokens, name, function (...args) {
392
+ const element = owners.get(this);
393
+ beforeChange(element, (copy) => original.apply(copy.relList, args));
394
+ return original.apply(this, args);
395
+ });
396
+ }
397
+ }
398
+ const tokenValue = accessor(tokens, "value");
399
+ if (tokenValue && tokenValue.set) {
400
+ Object.defineProperty(tokens, "value", {
401
+ configurable: false,
402
+ enumerable: tokenValue.enumerable,
403
+ get: tokenValue.get,
404
+ set(value) {
405
+ beforeChange(owners.get(this), (copy) => tokenValue.set.call(copy.relList, value));
406
+ tokenValue.set.call(this, value);
407
+ },
408
+ });
409
+ }
410
+
411
+ let root = document.getElementById("porthole-root");
412
+ let reported = 0;
413
+ const report = (message) => {
414
+ if (!declared("reportError") || reported >= plan.maxErrorReports) {
415
+ return;
416
+ }
417
+ reported += 1;
418
+ send({ method: "notifications/message", params: { level: "error", logger: "porthole", data: String(message).slice(0, 2000) } });
419
+ };
420
+ const send = (message) => {
421
+ if (plan.protocol === "shell" && window.parent !== window) {
422
+ // Nothing secret is ever said from here, so any parent may hear it; what it hears is the
423
+ // content's word, which the host gates before it acts on it.
424
+ window.parent.postMessage(Object.assign({ jsonrpc: "2.0" }, message), "*");
425
+ }
426
+ };
427
+
428
+ // A parsed tree's scripts are held (each replaced by a numbered marker) and run later, in order,
429
+ // as the page's parser would run them: each made again, live, in its marker's place, and each
430
+ // external one loaded before the next runs. Markers are found in the live document by number, so
431
+ // this works the same for a fragment that was moved in and a document that was imported.
432
+ let held = 0;
433
+ const holdScripts = (tree, owner) => {
434
+ const scripts = [];
435
+ for (const script of Array.from(tree.querySelectorAll("script"))) {
436
+ const marker = owner.createComment("porthole-script:" + held);
437
+ held += 1;
438
+ script.replaceWith(marker);
439
+ scripts.push({ script, id: marker.data });
440
+ }
441
+ return scripts;
442
+ };
443
+ const markers = () => {
444
+ const found = new Map();
445
+ const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
446
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
447
+ if (node.data.indexOf("porthole-script:") === 0) {
448
+ found.set(node.data, node);
449
+ }
450
+ }
451
+ return found;
452
+ };
453
+ const runScripts = async (scripts) => {
454
+ const placed = markers();
455
+ for (const { script, id } of scripts) {
456
+ const marker = placed.get(id);
457
+ if (marker === undefined) {
458
+ continue;
459
+ }
460
+ const live = document.createElement("script");
461
+ for (const attribute of Array.from(script.attributes)) {
462
+ live.setAttribute(attribute.name, attribute.value);
463
+ }
464
+ // A script made by a script is async unless told otherwise: keep the page's order.
465
+ live.async = script.hasAttribute("async");
466
+ live.textContent = script.textContent;
467
+ const external = live.hasAttribute("src") && !live.async && live.type !== "module";
468
+ const loaded = external
469
+ ? new Promise((resolve) => {
470
+ live.addEventListener("load", resolve);
471
+ live.addEventListener("error", () => {
472
+ report("Couldn't load " + live.src);
473
+ resolve();
474
+ });
475
+ })
476
+ : null;
477
+ marker.replaceWith(live);
478
+ if (loaded !== null) {
479
+ await loaded;
480
+ }
481
+ }
482
+ };
483
+ // The page's own load events, for code that waits for them: its scripts ran after the shell's
484
+ // document had loaded, so they fire again, once, after them.
485
+ const announceLoaded = () => {
486
+ document.dispatchEvent(new Event("DOMContentLoaded", { bubbles: true }));
487
+ window.dispatchEvent(new Event("load"));
488
+ };
489
+
490
+ // A whole document, parsed inert (no scripts run, no declarative shadow roots built), guarded,
491
+ // imported in place of the shell's, then its scripts run in order. The theme on the root stays.
492
+ const renderDocument = async (html) => {
493
+ const parsed = new DOMParser().parseFromString(String(html), "text/html");
494
+ strip(parsed);
495
+ const scripts = holdScripts(parsed, parsed);
496
+ const element = document.documentElement;
497
+ for (const attribute of Array.from(parsed.documentElement.attributes)) {
498
+ if (attribute.name !== "style") {
499
+ element.setAttribute(attribute.name, attribute.value);
500
+ }
501
+ }
502
+ document.head.replaceChildren(...Array.from(parsed.head.childNodes, (node) => document.importNode(node, true)));
503
+ document.body.replaceWith(document.importNode(parsed.body, true));
504
+ root = null;
505
+ await runScripts(scripts);
506
+ announceLoaded();
507
+ };
508
+
509
+ // A document the plan carries (a served page, an MCP Apps view) is rendered once the shell's own
510
+ // document has loaded, whichever protocol the frame speaks.
511
+ const whenLoaded = (run) => {
512
+ if (document.readyState === "complete") {
513
+ run();
514
+ } else {
515
+ window.addEventListener("load", run, { once: true });
516
+ }
517
+ };
518
+
519
+ if (plan.protocol !== "shell") {
520
+ // A view that speaks for itself.
521
+ if (typeof plan.document === "string") {
522
+ whenLoaded(() => void renderDocument(plan.document));
523
+ }
524
+ return;
525
+ }
526
+
527
+ // Content in this window may speak MCP Apps too (the official App SDK numbers its requests):
528
+ // the shell's ids are its own strings, so neither takes the other's answers.
529
+ let nextId = 1;
530
+ const pending = new Map();
531
+ const request = (method, params) =>
532
+ new Promise((resolve, reject) => {
533
+ const id = "porthole-shell-" + nextId++;
534
+ pending.set(id, { resolve, reject });
535
+ send({ id, method, params });
536
+ });
537
+ const notify = (method, params) => send({ method, params });
538
+
539
+ const themeNames = new Set(plan.themeVariables);
540
+ const applyTheme = (context) => {
541
+ if (context === null || typeof context !== "object") {
542
+ return;
543
+ }
544
+ // The spec's variables, and Porthole's own beside them (its own-prefixed field).
545
+ const own = context["porthole/styles"];
546
+ for (const variables of [context.styles && context.styles.variables, own && own.variables]) {
547
+ if (variables === null || typeof variables !== "object") {
548
+ continue;
549
+ }
550
+ for (const name of Object.keys(variables)) {
551
+ const value = variables[name];
552
+ if (themeNames.has(name) && typeof value === "string") {
553
+ document.documentElement.style.setProperty(name, value);
554
+ }
555
+ }
556
+ }
557
+ if (context.theme === "light" || context.theme === "dark") {
558
+ document.documentElement.style.colorScheme = context.theme;
559
+ document.documentElement.setAttribute("data-theme", context.theme);
560
+ }
561
+ window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
562
+ };
563
+
564
+ // No size before there is something to size: an empty shell's height would make the host
565
+ // forget the height it remembered for this display, and the frame would jump.
566
+ let drawn = false;
567
+ let lastHeight = -1;
568
+ const measure = () => {
569
+ if (!declared("resize") || !drawn) {
570
+ return;
571
+ }
572
+ const box = root !== null && root.isConnected ? root : document.documentElement;
573
+ const rect = box.getBoundingClientRect();
574
+ const height = Math.ceil(box === root ? Math.max(rect.height, root.scrollHeight) : rect.height);
575
+ if (height !== lastHeight) {
576
+ lastHeight = height;
577
+ notify("ui/notifications/size-changed", { width: Math.ceil(rect.width), height });
578
+ }
579
+ };
580
+ let scheduled = false;
581
+ const schedule = () => {
582
+ if (!scheduled) {
583
+ scheduled = true;
584
+ requestAnimationFrame(() => {
585
+ scheduled = false;
586
+ measure();
587
+ });
588
+ }
589
+ };
590
+
591
+ const gestured = (name) => {
592
+ const capability = plan.capabilities[name];
593
+ const activation = navigator.userActivation;
594
+ return !(capability && capability.userActivation) || !activation || activation.isActive;
595
+ };
596
+ const refuse = (message) => Promise.reject(new Error(message));
597
+ if (declared("sendPrompt")) {
598
+ lock(window, "sendPrompt", (text) => {
599
+ if (!gestured("sendPrompt")) {
600
+ return refuse("sendPrompt needs a click: call it from a button's handler");
601
+ }
602
+ const words = String(text);
603
+ if (words.trim() === "" || words.length > plan.maxPromptChars) {
604
+ return refuse("sendPrompt takes 1 to " + plan.maxPromptChars + " characters");
605
+ }
606
+ return request("ui/message", { role: "user", content: [{ type: "text", text: words }] }).then(
607
+ (result) => !(result && result.isError),
608
+ );
609
+ });
610
+ }
611
+ if (declared("openLink")) {
612
+ lock(window, "openLink", (url) => {
613
+ if (!gestured("openLink")) {
614
+ return refuse("openLink needs a click: call it from a link's or button's handler");
615
+ }
616
+ return request("ui/open-link", { url: String(url) }).then((result) => !(result && result.isError));
617
+ });
618
+ }
619
+
620
+ window.addEventListener("error", (event) => {
621
+ const error = event.error;
622
+ const where = event.lineno ? " (line " + event.lineno + ")" : "";
623
+ report(error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : event.message + where);
624
+ });
625
+ window.addEventListener("unhandledrejection", (event) => {
626
+ const reason = event.reason;
627
+ report("Unhandled rejection: " + (reason && reason.message ? reason.message : String(reason)));
628
+ });
629
+ document.addEventListener("securitypolicyviolation", (event) => {
630
+ report("Blocked by the sandbox: " + (event.blockedURI || "inline") + " (" + event.effectiveDirective + ")");
631
+ });
632
+ // A link never navigates this frame: an http(s) one opens outside, through the host, and an
633
+ // in-page anchor still jumps. SVG links (href or xlink:href) and image-map areas too.
634
+ document.addEventListener(
635
+ "click",
636
+ (event) => {
637
+ const target = event.target;
638
+ const link = target && target.closest ? target.closest("a, area") : null;
639
+ if (link === null) {
640
+ return;
641
+ }
642
+ const href = link.getAttribute("href") || link.getAttributeNS("http://www.w3.org/1999/xlink", "href") || "";
643
+ if (href === "" || href.charAt(0) === "#") {
644
+ return;
645
+ }
646
+ event.preventDefault();
647
+ let url = "";
648
+ try {
649
+ url = new URL(href, document.baseURI).href;
650
+ } catch (_) {}
651
+ if (/^https?:/i.test(url) && typeof window.openLink === "function") {
652
+ window.openLink(url).catch(() => {});
653
+ }
654
+ },
655
+ true,
656
+ );
657
+ if (typeof ResizeObserver === "function") {
658
+ const sizes = new ResizeObserver(schedule);
659
+ sizes.observe(document.documentElement);
660
+ if (root !== null) {
661
+ sizes.observe(root);
662
+ }
663
+ }
664
+ window.addEventListener("load", schedule);
665
+
666
+ let rendered = false;
667
+
668
+ // The content so far, drawn inert: what runs, loads a frame or navigates is left out, and every
669
+ // attribute that runs code (on…) or names a javascript: URL. Parsed in a template, where nothing
670
+ // runs; its scripts run only in the whole content, once (render).
671
+ const INERT = "script,iframe,frame,frameset,object,embed,applet,portal,fencedframe,meta,base,noscript,link:not([rel~=stylesheet i])";
672
+ const inert = (fragment) => {
673
+ for (const element of Array.from(fragment.querySelectorAll(INERT))) {
674
+ element.remove();
675
+ }
676
+ for (const element of Array.from(fragment.querySelectorAll("*"))) {
677
+ for (const attribute of Array.from(element.attributes)) {
678
+ const name = attribute.name.toLowerCase();
679
+ if (name.startsWith("on") || /^\s*javascript:/i.test(attribute.value)) {
680
+ element.removeAttributeNode(attribute);
681
+ }
682
+ }
683
+ }
684
+ return fragment;
685
+ };
686
+ // Change only what changed: what was drawn stays (an image doesn't load again, an animation
687
+ // doesn't start over) as the content grows at its end.
688
+ const morph = (into, from) => {
689
+ const wanted = Array.from(from.childNodes);
690
+ wanted.forEach((want, index) => {
691
+ const have = into.childNodes[index];
692
+ if (have === undefined) {
693
+ into.appendChild(want);
694
+ } else if (have.nodeType !== want.nodeType || have.nodeName !== want.nodeName || have.namespaceURI !== want.namespaceURI) {
695
+ into.replaceChild(want, have);
696
+ } else if (want.nodeType === 1) {
697
+ for (const attribute of Array.from(have.attributes)) {
698
+ if (!want.hasAttributeNS(attribute.namespaceURI, attribute.localName)) {
699
+ have.removeAttributeNode(attribute);
700
+ }
701
+ }
702
+ for (const attribute of Array.from(want.attributes)) {
703
+ if (have.getAttributeNS(attribute.namespaceURI, attribute.localName) !== attribute.value) {
704
+ have.setAttributeNS(attribute.namespaceURI, attribute.name, attribute.value);
705
+ }
706
+ }
707
+ morph(have, want);
708
+ } else if (have.nodeValue !== want.nodeValue) {
709
+ have.nodeValue = want.nodeValue;
710
+ }
711
+ });
712
+ while (into.childNodes.length > wanted.length) {
713
+ into.removeChild(into.lastChild);
714
+ }
715
+ };
716
+ let drafted = false;
717
+ const draft = (input) => {
718
+ if (rendered || root === null || input === null || typeof input !== "object" || plan.content === "document") {
719
+ return;
720
+ }
721
+ morph(root, inert(parseInert(input.html || "")));
722
+ drafted = true;
723
+ drawn = true;
724
+ schedule();
725
+ };
726
+
727
+ const SVG = /^\s*(<\?xml[\s\S]*?\?>\s*)?(<!--[\s\S]*?-->\s*)*<svg[\s>]/i;
728
+ const render = async (input) => {
729
+ if (rendered || input === null || typeof input !== "object") {
730
+ return;
731
+ }
732
+ rendered = true;
733
+ drawn = true;
734
+ const html = String(input.html || "");
735
+ if (plan.content === "document") {
736
+ await renderDocument(html);
737
+ schedule();
738
+ return;
739
+ }
740
+ if (root === null) {
741
+ return;
742
+ }
743
+ if (drafted) {
744
+ // The whole content replaces its draft, and runs as if drawn first.
745
+ root.textContent = "";
746
+ }
747
+ // SVG goes in as drawn: its scripts, if any, stay inert.
748
+ const fragment = parseInert(html);
749
+ strip(fragment);
750
+ const scripts = SVG.test(html) ? [] : holdScripts(fragment, document);
751
+ root.appendChild(fragment);
752
+ schedule();
753
+ await runScripts(scripts);
754
+ announceLoaded();
755
+ schedule();
756
+ };
757
+
758
+ const receive = (data) => {
759
+ if (data === null || typeof data !== "object" || data.jsonrpc !== "2.0") {
760
+ return;
761
+ }
762
+ if (data.id !== undefined && data.method === undefined) {
763
+ const waiting = pending.get(data.id);
764
+ if (waiting !== undefined) {
765
+ pending.delete(data.id);
766
+ if (data.error) {
767
+ waiting.reject(new Error(String(data.error.message || "refused")));
768
+ } else {
769
+ waiting.resolve(data.result);
770
+ }
771
+ }
772
+ return;
773
+ }
774
+ const params = data.params !== null && typeof data.params === "object" ? data.params : {};
775
+ switch (data.method) {
776
+ case "ui/notifications/tool-input-partial":
777
+ draft(params.arguments);
778
+ break;
779
+ case "ui/notifications/tool-input":
780
+ void render(params.arguments);
781
+ break;
782
+ case "ui/notifications/host-context-changed":
783
+ applyTheme(params);
784
+ break;
785
+ case "ui/resource-teardown":
786
+ case "ping":
787
+ if (data.id !== undefined) {
788
+ send({ id: data.id, result: {} });
789
+ }
790
+ break;
791
+ default:
792
+ if (data.id !== undefined) {
793
+ send({ id: data.id, error: { code: -32601, message: String(data.method) + " isn't offered here" } });
794
+ }
795
+ break;
796
+ }
797
+ };
798
+ window.addEventListener("message", (event) => {
799
+ if (event.source !== window.parent || plan.origins.indexOf(event.origin) === -1) {
800
+ return;
801
+ }
802
+ receive(event.data);
803
+ });
804
+ if (typeof plan.document === "string") {
805
+ whenLoaded(() => void render({ html: plan.document }));
806
+ }
807
+
808
+ // Ask until answered: the host may start listening after this runs.
809
+ let initialized = false;
810
+ let tries = 0;
811
+ const initialize = () => {
812
+ if (initialized || tries >= 40) {
813
+ return;
814
+ }
815
+ tries += 1;
816
+ request("ui/initialize", {
817
+ protocolVersion: plan.protocolVersion,
818
+ appInfo: { name: "porthole-shell", version: "1" },
819
+ appCapabilities: {},
820
+ }).then((result) => {
821
+ if (initialized) {
822
+ return;
823
+ }
824
+ initialized = true;
825
+ applyTheme(result && result.hostContext);
826
+ notify("ui/notifications/initialized", {});
827
+ schedule();
828
+ }, () => {});
829
+ setTimeout(initialize, 250);
830
+ };
831
+ initialize();
832
+ })();`;