// SPDX-License-Identifier: GPL-3.0-only
/**
* Inferway Studio — in-iframe shim for the pinned ComfyUI frontend dist
* (comfyui-frontend-package==1.51.10), Studio L3b slice 2, requirements 2/6/9.
*
* WHAT THIS IS
* ------------
* A plain, unminified, dependency-free ES2020 script. It is both the file that
* runs inside the sandboxed editor document AND the published source of what
* runs there — nothing is bundled, minified or generated.
*
* The build injects ONE line into the dist's `index.html`:
*
*
*
* as the first element of `
`, so this classic script executes before the
* module bundle. Before the frontend starts it:
*
* 0. pulls in `inferway-bridge-protocol.js` (a classic script, written into
* the parse stream with document.write, so it runs before the bundle
* too) — the closed message-type list, schema validation and size caps;
* 1. replaces localStorage / sessionStorage with in-memory Storage
* implementations (the native ones throw SecurityError under the sandbox),
* and — only when this frame's own url carries `?workflow=` — seeds
* the two values the frontend reads to decide WHICH workflow to open
* (its frame-local `clientId` and the active-path pointer); see
* `seedSelectedWorkflow` below (Studio L4c);
* 2. defines `document.cookie` so reads return "" and writes are ignored,
* without ever throwing;
* 3. replaces `fetch` and `XMLHttpRequest`:
* - `/api/…` → never sent: becomes a postMessage request
* to the parent page, resolved with the parent's answer;
* - same-directory static assets (`/…`) → pass through to
* the native fetch unchanged;
* - `data:` and this document's own `blob:` URLs → decoded in memory
* (no network, no CSP: `connect-src 'self'` refuses both, and the
* inline `data:application/wasm` fetch must not raise an uncaught
* rejection at boot);
* - anything else (api.comfy.org, any other origin, `/v1/…`, …) →
* refused: a thrown/rejected error plus a console.error naming the
* path. Never silently allowed.
* 4. replaces `WebSocket`: the editor's own `ws` URL gets an inert stub that
* reports OPEN and never touches the network; any other WebSocket URL
* throws (with a console.error);
* 5. replaces `alert` / `confirm` / `prompt` with in-editor DOM dialogs.
* The native calls are synchronous, a DOM dialog cannot be, so the
* replacement returns immediately with the SAFE default — `false`
* (cancel) for confirm, `null` (nothing typed) for prompt — and shows the
* message in a dismissible in-editor dialog. Every native `confirm(` call
* site in this dist is destructive (clear workflow / load default
* workflow / follow a dangerous link), so cancel is the safe answer;
* there is no native `alert(` or `prompt(` call site in 1.51.10;
* 6. routes downloads (anchor clicks with the `download` attribute — which
* is how every `createObjectURL` save reaches the disk) and clipboard
* WRITES to the parent as bridge messages. Clipboard READS (paste) are
* not implemented: a read would let an untrusted frame pull the user's
* clipboard at any moment, and the parent has no gesture to gate it on;
* 7. turns the frontend's version check against `api.comfy.org` off through
* a SETTING the bridge answers (`Comfy.Notification.ShowVersionUpdates`
* = false in the default settings table), not by patching the frontend.
* See `website/src/lib/studioBridge.ts`.
*
* The editor document is untrusted: this file never sends a credential, never
* opens a socket, and never lets a request leave the frame without a decision.
*/
(function () {
"use strict";
var SHIM_VERSION = "inferway-shim/1 (comfyui-frontend 1.51.10)";
var LOG_PREFIX = "[inferway-shim] ";
// -------------------------------------------------------------------------
// 0. Shared protocol + editor directory
// -------------------------------------------------------------------------
function protocol() {
var api = window.InferwayBridgeProtocol;
if (!api) {
console.error(LOG_PREFIX + "inferway-bridge-protocol.js did not load; the bridge is disabled");
return null;
}
return api;
}
/**
* Pull the protocol file into the parse stream. document.write during
* parsing inserts the tag right after this script, so it is fetched and
* executed before the module bundle — no async ordering to get wrong. The
* guard keeps a late/second evaluation from ever wiping the document.
*/
function loadProtocol() {
if (window.InferwayBridgeProtocol) return;
if (document.readyState !== "loading") return;
try {
document.write('');
} catch (error) {
console.error(LOG_PREFIX + "could not load the bridge protocol: " + error);
}
}
/**
* The directory the dist is served under, e.g. `/console/studio/editor`.
* Computed once, at load, from the document URL — exactly how the frontend
* itself derives `api_base` (`location.pathname.split("/").slice(0,-1)`),
* and before any client-side navigation could change the pathname.
*/
function computeEditorDir() {
var path = location.pathname;
var parts = path.split("/");
var last = parts[parts.length - 1];
if (last.indexOf(".") >= 0 || path.charAt(path.length - 1) === "/") parts.pop();
var dir = parts.join("/");
return dir === "" ? "/" : dir;
}
var EDITOR_DIR = computeEditorDir();
var stats = {
bridgeRequests: 0,
blockedRequests: 0,
passthroughRequests: 0,
blockedWebSockets: 0,
inertWebSockets: 0,
dialogs: 0,
downloads: 0,
clipboardWrites: 0,
blockedClipboardReads: 0,
};
/**
* Classify one URL for fetch/XHR:
* {kind:"bridge"} `/api/…` → postMessage to the parent
* {kind:"passthrough"} same-directory static asset, blob:, data:
* {kind:"refuse"} everything else — including every other origin
*/
function classifyUrl(raw) {
var url;
try {
url = new URL(String(raw), location.href);
} catch (error) {
return { kind: "refuse", reason: "unresolved URL", path: String(raw) };
}
var scheme = url.protocol;
if (scheme === "blob:" || scheme === "data:" || scheme === "about:" || scheme === "chrome:" || scheme === "chrome-error:") {
return { kind: "passthrough", path: url.href, url: url };
}
if (scheme !== "http:" && scheme !== "https:") {
return { kind: "refuse", reason: "scheme " + scheme, path: url.href };
}
if (url.host !== location.host) {
return { kind: "refuse", reason: "origin " + url.host + " is not the editor origin", path: url.href };
}
var path = url.pathname;
var inEditorDir = path === EDITOR_DIR || path.indexOf(EDITOR_DIR + "/") === 0;
if (!inEditorDir) {
return { kind: "refuse", reason: "outside the editor prefix " + EDITOR_DIR, path: path };
}
var rest = path.slice(EDITOR_DIR.length);
if (rest === "/api" || rest.indexOf("/api/") === 0) {
var apiPath = rest.slice("/api".length);
if (apiPath === "") apiPath = "/";
return { kind: "bridge", apiPath: apiPath + url.search, path: path + url.search };
}
return { kind: "passthrough", path: path + url.search, url: url };
}
/** The editor's own socket, or a refusal. Anything else must throw. */
function classifyWs(raw) {
var url;
try {
url = new URL(String(raw), location.href);
} catch (error) {
return { kind: "refuse", reason: "unresolved URL", path: String(raw) };
}
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
return { kind: "refuse", reason: "scheme " + url.protocol, path: url.href };
}
if (url.host !== location.host) {
return { kind: "refuse", reason: "origin " + url.host + " is not the editor origin", path: url.href };
}
if (url.pathname === EDITOR_DIR + "/ws") return { kind: "inert", url: url };
return { kind: "refuse", reason: "not the editor socket", path: url.pathname };
}
function refuseRequest(how, kind, detail) {
var message = LOG_PREFIX + "blocked " + kind + " request: " + detail;
stats.blockedRequests += 1;
console.error(message);
var error = new Error(message);
error.name = "InferwayStudioBlockedRequest";
if (how === "throw") throw error;
return Promise.reject(error);
}
// -------------------------------------------------------------------------
// 1. Storage — in-memory, because the sandboxed document has none
// -------------------------------------------------------------------------
function createMemoryStorage() {
var store = Object.create(null);
return {
getItem: function (key) {
var name = String(key);
return Object.prototype.hasOwnProperty.call(store, name) ? store[name] : null;
},
setItem: function (key, value) {
store[String(key)] = String(value);
},
removeItem: function (key) {
delete store[String(key)];
},
clear: function () {
for (var name in store) {
if (Object.prototype.hasOwnProperty.call(store, name)) delete store[name];
}
},
key: function (index) {
var keys = Object.keys(store);
return index >= 0 && index < keys.length ? keys[index] : null;
},
get length() {
return Object.keys(store).length;
},
};
}
function defineOnWindow(name, value) {
try {
Object.defineProperty(window, name, { value: value, configurable: true, writable: true });
return;
} catch (error) {
/* fall through to a plain assignment */
}
try {
window[name] = value;
} catch (error2) {
console.error(LOG_PREFIX + "could not replace " + name + ": " + error2);
}
}
/**
* Register a listener through ONE list, kept on the window: a second
* evaluation of this file (a re-injection) removes the previous generation
* instead of stacking a duplicate handler on top of it.
*/
function removeAllPreviousListeners() {
var previous = window.__inferwayStudioListeners;
if (!Array.isArray(previous)) return;
for (var i = 0; i < previous.length; i += 1) {
var entry = previous[i];
entry.target.removeEventListener(entry.type, entry.handler, entry.capture);
}
}
var listenerRegistrations = [];
function listen(target, type, handler, capture) {
target.addEventListener(type, handler, capture);
listenerRegistrations.push({ target: target, type: type, handler: handler, capture: capture });
}
/**
* The native fetch, kept before the replacement so http(s) passthrough still works.
*/
var nativeFetchForBridge = null;
/**
* Object URLs this document created, so a `blob:` read can be served from
* memory. `connect-src 'self'` does NOT admit `blob:` or `data:` — a native
* fetch of either is refused by CSP — and neither needs the network: they
* are decoded here, which also keeps the inline `data:application/wasm`
* fetch three.js makes from raising an uncaught rejection at boot.
*/
var objectUrlBlobs = Object.create(null);
function base64ToBytesLocal(base64) {
var binary = window.atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function decodeDataUrl(href) {
var comma = href.indexOf(",");
if (comma < 0) throw new Error("malformed data URL");
var meta = href.slice(5, comma);
var payload = href.slice(comma + 1);
var isBase64 = /;base64$/i.test(meta);
var type = isBase64 ? meta.replace(/;base64$/i, "") : meta;
var bytes;
if (isBase64) bytes = base64ToBytesLocal(payload);
else {
// Non-base64 payload: percent-encoded text. This line used to call
// `new TextEncoder().decode(…)` — `TextEncoder` has NO `decode`, so it
// threw, fell out of the caller's try and sent the URL to the
// CSP-blocked native fetch instead. A `TextDecoder` cannot build the
// body either: it consumes bytes and rejects the string
// `decodeURIComponent` returns. Encoding the decoded text is the exact
// inverse operation a `Response` needs (it re-encodes UTF-8 the same
// way), so the bytes a `data:` URL names are the bytes delivered.
bytes = new TextEncoder().encode(decodeURIComponent(payload));
}
return new Response(bytes, {
status: 200,
headers: { "content-type": type === "" ? "text/plain;charset=utf-8" : type },
});
}
/** A Response for a data:/blob: URL this frame can answer locally, else null. */
function localResponse(url) {
try {
if (url.protocol === "data:") return decodeDataUrl(url.href);
if (url.protocol === "blob:") {
var blob = objectUrlBlobs[url.href];
if (!blob) return null;
return blob.arrayBuffer().then(function (buffer) {
return new Response(buffer, {
status: 200,
headers: { "content-type": blob.type || "application/octet-stream" },
});
});
}
} catch (error) {
console.error(LOG_PREFIX + "could not decode " + url.href.slice(0, 64) + ": " + error);
}
return null;
}
function installStorage() {
defineOnWindow("localStorage", createMemoryStorage());
defineOnWindow("sessionStorage", createMemoryStorage());
}
// -------------------------------------------------------------------------
// 1b. The selected workflow — the one thing the PARENT decides
// -------------------------------------------------------------------------
/**
* WHICH workflow this frame was opened for (Studio L4c, requirement: the
* parent never initiates a message, so the editor has to PULL).
*
* The protocol lets this document ask the parent only what it would have
* asked a ComfyUI server; nothing in the upstream frontend asks "which
* workflow should I open?" at boot — that choice lives in this document's
* own storage (`Comfy.Workflow.LastActivePath:` →
* `{workspaceId, path}`, read by the frontend's `readActivePath` and used by
* `initializeWorkflow` → `loadPreviousWorkflowFromStorage`), which the parent
* cannot write: this frame has an opaque origin and the channel is one-way.
*
* So the frame reads the id out of ITS OWN url — `?workflow=`, put there
* by the parent in the iframe `src` — and writes two storage values before
* any of the frontend runs:
*
* 1. `sessionStorage.clientId` — upstream takes this from the socket's
* `status` frame, and this frame's socket is inert by design, so
* without it `getActivePath()` returns `null` before it looks at ANY
* storage and step 2 could never be read;
* 2. `localStorage["Comfy.Workflow.LastActivePath:personal"]`
* = `{workspaceId:"personal", path:"workflows/.json"}` — the
* pointer the frontend's `readActivePath` falls back to.
*
* Everything after that is upstream's own work, over the existing
* `api-request` message type:
*
* initializeWorkflow → loadWorkflows() GET /userdata?dir=workflows…
* → loadPreviousWorkflowFromStorage()
* → getWorkflowByPath("workflows/.json")
* → openWorkflow(...) GET /userdata/workflows%2F.json
*
* No new message type, no parent-initiated send, no protocol change: a pull
* over two message shapes the frontend already sends. If anything here does
* not match — a different id, no query, storage unavailable — nothing is
* written and the editor boots exactly as it does today (blank), which is
* the only correct failure mode for a hint.
*/
/** Ids are opaque to this file: letters, digits, `_` and `-` only — no `.`,
* so no traversal can ever be spelled into the pointer. */
var WORKFLOW_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
/** `getWorkspaceId()` in the pinned dist returns this constant (1.51.10). */
var WORKSPACE_ID = "personal";
/** `Bs.lastActivePath(workspaceId)` in the pinned dist. */
var LAST_ACTIVE_PATH_KEY = "Comfy.Workflow.LastActivePath:" + WORKSPACE_ID;
/** Where `api` reads `initialClientId` at construction (upstream name). */
var CLIENT_ID_KEY = "clientId";
/**
* A frame-local client id, not a credential and never sent anywhere: the
* socket that would have minted one is inert, run control is answered 503
* by the parent, and the only other thing it keys is frame-local storage.
*/
var CLIENT_ID_VALUE = "inferway-studio";
function selectedWorkflowPath() {
var raw = null;
try {
raw = new URLSearchParams(location.search).get("workflow");
} catch (error) {
raw = null;
}
if (typeof raw !== "string" || !WORKFLOW_ID_PATTERN.test(raw)) return null;
return "workflows/" + raw + ".json";
}
function seedSelectedWorkflow() {
var path = selectedWorkflowPath();
if (!path) return;
var pointer = JSON.stringify({ workspaceId: WORKSPACE_ID, path: path });
try {
if (!sessionStorage.getItem(CLIENT_ID_KEY)) sessionStorage.setItem(CLIENT_ID_KEY, CLIENT_ID_VALUE);
localStorage.setItem(LAST_ACTIVE_PATH_KEY, pointer);
} catch (error) {
console.error(LOG_PREFIX + "could not seed the selected workflow: " + error);
}
}
// -------------------------------------------------------------------------
// 2. document.cookie — reads "", writes ignored, never throws
// -------------------------------------------------------------------------
function installCookieShim() {
var descriptor = { get: function () { return ""; }, set: function () {}, configurable: true };
try {
Object.defineProperty(Document.prototype, "cookie", descriptor);
return;
} catch (prototypeError) {
/* fall through to the instance */
}
try {
Object.defineProperty(document, "cookie", descriptor);
} catch (error) {
console.error(LOG_PREFIX + "could not shim document.cookie: " + error);
}
}
// -------------------------------------------------------------------------
// 3. Bridge transport — postMessage requests to the parent, one reply each
// -------------------------------------------------------------------------
var pending = Object.create(null);
var requestSequence = 0;
var protocolWarned = false;
var originWarned = false;
function nextId() {
requestSequence += 1;
return "iws" + requestSequence + "-" + Math.random().toString(36).slice(2, 10);
}
function warnProtocolOnce() {
if (protocolWarned) return;
protocolWarned = true;
console.error(LOG_PREFIX + "bridge protocol unavailable; requests cannot be answered");
}
function warnOriginOnce() {
if (originWarned) return;
originWarned = true;
console.error(
LOG_PREFIX +
"this document has no usable origin (location.origin is \"null\" or empty); the bridge request was not sent"
);
}
/**
* Send one request and resolve with the parent's single answer. `timeout`
* keeps a dropped frame from hanging the editor forever.
*/
function sendRequest(type, fields, timeout) {
var api = protocol();
if (!api) {
warnProtocolOnce();
return Promise.reject(new Error(LOG_PREFIX + "bridge protocol unavailable"));
}
if (!window.parent || window.parent === window) {
var noParent = new Error(LOG_PREFIX + "no parent frame: " + type + " cannot be answered here");
console.error(noParent.message);
return Promise.reject(noParent);
}
// The parent frame is this document's own origin: `frame-ancestors 'self'`
// admits only the site itself, and `location.origin` is the origin of THIS
// document's URL. An opaque ("null") or empty origin names no window at
// all, so send nothing rather than fall back to "*".
var targetOrigin = location.origin;
if (!targetOrigin || targetOrigin === "null") {
warnOriginOnce();
return Promise.reject(new Error(LOG_PREFIX + "no usable parent origin: " + type + " was not sent"));
}
var message = { v: api.PROTOCOL_VERSION, type: type, id: nextId() };
for (var key in fields) {
if (Object.prototype.hasOwnProperty.call(fields, key)) message[key] = fields[key];
}
var check = api.validateMessage(message);
if (!check.ok) {
console.error(LOG_PREFIX + "refusing to send an invalid " + type + ": " + check.reason);
return Promise.reject(new Error(LOG_PREFIX + "invalid outbound message: " + check.reason));
}
stats.bridgeRequests += 1;
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () {
delete pending[message.id];
var reason = LOG_PREFIX + "bridge timeout after " + timeout + "ms for " + type;
console.error(reason);
reject(new Error(reason));
}, timeout);
pending[message.id] = { resolve: resolve, reject: reject, timer: timer, responseType: api.RESPONSE_TYPE_FOR[type] };
// The site's own origin names the parent; the parent re-checks the
// frame on every inbound message as well.
window.parent.postMessage(message, targetOrigin);
});
}
function isResponseType(type) {
return type === "api-response" || type === "download-ack" || type === "clipboard-ack";
}
function installMessageListener() {
listen(window, "message", function (event) {
// Editor side: only the parent may answer. `event.origin` is "null" for
// this document, so the SOURCE is the identity check (requirement 5).
if (event.source !== window.parent) return;
var message = event.data;
var api = protocol();
if (!api) return;
var check = api.validateMessage(message);
if (!check.ok) {
console.error(LOG_PREFIX + "rejected bridge message: " + check.reason);
return;
}
if (!isResponseType(message.type)) {
console.error(LOG_PREFIX + "rejected bridge message: the editor only accepts responses, got " + message.type);
return;
}
var entry = pending[message.id];
if (!entry) {
console.error(LOG_PREFIX + "rejected bridge message: no pending request with id " + message.id);
return;
}
if (entry.responseType !== message.type) {
console.error(LOG_PREFIX + "rejected bridge message: id " + message.id + " expects " + entry.responseType);
return;
}
delete pending[message.id];
clearTimeout(entry.timer);
entry.resolve(message);
});
}
function responseFrom(message) {
var contentType = message.contentType || "application/json";
var init = { status: message.status, headers: { "content-type": contentType } };
if (message.status === 204 || message.status === 205 || message.status === 304) {
return new Response(null, init);
}
return new Response(message.body, init);
}
/** api/… fetch: to the parent and back, never on the wire. */
function bridgeFetch(apiPath, method, body) {
return sendRequest("api-request", { method: method, path: apiPath, body: body }, REQUEST_TIMEOUT_MS).then(
function (answer) {
if (answer.status >= 500 && answer.status <= 599) {
console.error(LOG_PREFIX + "parent could not answer " + method + " " + apiPath + ": HTTP " + answer.status);
}
return responseFrom(answer);
}
);
}
var REQUEST_TIMEOUT_MS = 15000;
function bodyToString(body) {
if (body === undefined || body === null) return null;
if (typeof body === "string") return body;
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return body.toString();
return { unsupported: true };
}
function readRequestBody(input, init) {
if (init && Object.prototype.hasOwnProperty.call(init, "body")) return Promise.resolve(init.body);
if (input && typeof input === "object" && typeof input.clone === "function" && input.body) {
return input.clone().text().catch(function () { return undefined; });
}
return Promise.resolve(undefined);
}
// -------------------------------------------------------------------------
// 4. fetch
// -------------------------------------------------------------------------
function installFetch() {
var nativeFetch = nativeFetchForBridge;
if (!nativeFetch) {
console.error(LOG_PREFIX + "no native fetch on this engine; static passthrough is unavailable");
}
var replacement = function (input, init) {
var raw = input && typeof input === "object" && typeof input.url === "string" ? input.url : String(input);
var kind = classifyUrl(raw);
if (kind.kind === "passthrough") {
var local = localResponse(kind.url);
if (local) {
stats.passthroughRequests += 1;
return Promise.resolve(local);
}
if (!nativeFetch) return Promise.reject(new Error(LOG_PREFIX + "fetch is unavailable"));
stats.passthroughRequests += 1;
return nativeFetch(input, init);
}
if (kind.kind === "refuse") return refuseRequest("reject", "fetch", kind.path + " (" + kind.reason + ")");
var method = (init && init.method) || (input && input.method) || "GET";
method = String(method).toUpperCase();
return readRequestBody(input, init).then(function (body) {
var text = bodyToString(body);
if (text && typeof text === "object" && text.unsupported) {
return refuseRequest(
"reject",
"fetch with a binary body to",
kind.path + " (uploads do not cross the bridge; only text bodies do)"
);
}
return bridgeFetch(kind.apiPath, method, text);
});
};
Object.defineProperty(replacement, "__inferwayStudioNative", {
value: nativeFetch,
configurable: true,
});
defineOnWindow("fetch", replacement);
}
// -------------------------------------------------------------------------
// 5. XMLHttpRequest — bridge for api/, native delegate for static, error
// for everything else. Axios (and therefore every api.comfy.org client)
// speaks XHR, so this path must be closed too.
// -------------------------------------------------------------------------
function ShimXHR() {
this.readyState = 0;
this.status = 0;
this.statusText = "";
this.response = null;
this.responseText = "";
this.responseType = "";
this.responseURL = "";
this.responseHeaders = null;
this.withCredentials = false;
this.upload = { addEventListener: function () {}, removeEventListener: function () {} };
this._listeners = Object.create(null);
this._native = null;
this._method = "GET";
this._url = "";
this._aborted = false;
this.onreadystatechange = null;
this.onload = null;
this.onerror = null;
this.onloadend = null;
this.onloadstart = null;
this.onabort = null;
this.ontimeout = null;
this.onprogress = null;
}
ShimXHR.prototype.addEventListener = function (type, listener) {
(this._listeners[type] || (this._listeners[type] = [])).push(listener);
};
ShimXHR.prototype.removeEventListener = function (type, listener) {
var list = this._listeners[type];
if (!list) return;
var index = list.indexOf(listener);
if (index >= 0) list.splice(index, 1);
};
ShimXHR.prototype.dispatchEvent = function (event) {
var type = event.type;
var handler = this["on" + type];
if (typeof handler === "function") handler.call(this, event);
var list = (this._listeners[type] || []).slice();
for (var i = 0; i < list.length; i += 1) list[i].call(this, event);
return true;
};
function xhrEvent(type, target) {
if (typeof Event === "function") return new Event(type, { target: target });
var event = { type: type, target: target };
return event;
}
ShimXHR.prototype._setReadyState = function (state) {
this.readyState = state;
this.dispatchEvent(xhrEvent("readystatechange", this));
};
ShimXHR.prototype.open = function (method, url, async, user, password) {
this._method = String(method || "GET").toUpperCase();
this._url = String(url);
this._async = async !== false;
this._aborted = false;
var kind = classifyUrl(this._url);
this._kind = kind;
if (kind.kind === "refuse") {
// Defer the failure: open() must not throw where the frontend does not
// expect it, but nothing may be sent either.
this._setReadyState(1);
return;
}
if (kind.kind === "passthrough") {
var NativeXHR = window.XMLHttpRequest.__native;
if (!NativeXHR) {
this._kind = { kind: "refuse", path: this._url, reason: "no native XHR to delegate to" };
this._setReadyState(1);
return;
}
this._native = new NativeXHR();
this._wireNative();
this._native.open(method, url, async, user, password);
this._setReadyState(this._native.readyState);
return;
}
this._setReadyState(1);
};
ShimXHR.prototype._wireNative = function () {
var self = this;
var native = this._native;
var types = ["readystatechange", "loadstart", "progress", "abort", "error", "load", "loadend", "timeout"];
for (var i = 0; i < types.length; i += 1) {
(function (type) {
native.addEventListener(type, function (event) {
self.readyState = native.readyState;
self.status = native.status;
self.statusText = native.statusText;
self.responseURL = native.responseURL;
try {
self.responseText = native.responseText;
} catch (_ignored) {
/* a binary responseType has no responseText */
}
self.response = native.response;
self.dispatchEvent(xhrEvent(type, self));
});
})(types[i]);
}
};
ShimXHR.prototype.setRequestHeader = function (name, value) {
if (this._native) this._native.setRequestHeader(name, value);
};
ShimXHR.prototype.getAllResponseHeaders = function () {
if (this._native) return this._native.getAllResponseHeaders();
if (!this.responseHeaders) return "";
var lines = [];
for (var name in this.responseHeaders) {
if (Object.prototype.hasOwnProperty.call(this.responseHeaders, name)) {
lines.push(name + ": " + this.responseHeaders[name]);
}
}
return lines.join("\r\n");
};
ShimXHR.prototype.getResponseHeader = function (name) {
if (this._native) return this._native.getResponseHeader(name);
if (!this.responseHeaders) return null;
var wanted = String(name).toLowerCase();
for (var key in this.responseHeaders) {
if (Object.prototype.hasOwnProperty.call(this.responseHeaders, key) && key.toLowerCase() === wanted) {
return this.responseHeaders[key];
}
}
return null;
};
ShimXHR.prototype.overrideMimeType = function (mime) {
if (this._native) this._native.overrideMimeType(mime);
this._mimeType = mime;
};
ShimXHR.prototype.abort = function () {
this._aborted = true;
if (this._native) {
this._native.abort();
return;
}
this._setReadyState(4);
this.dispatchEvent(xhrEvent("abort", this));
this.dispatchEvent(xhrEvent("loadend", this));
};
ShimXHR.prototype._fail = function (message) {
var self = this;
stats.blockedRequests += 1;
console.error(message);
this.status = 0;
this.statusText = "";
setTimeout(function () {
if (self._aborted) return;
self._setReadyState(4);
self.dispatchEvent(xhrEvent("error", self));
self.dispatchEvent(xhrEvent("loadend", self));
}, 0);
};
ShimXHR.prototype.send = function (body) {
var self = this;
if (this._native) {
this._native.send(body);
return;
}
if (!this._kind || this._kind.kind === "refuse") {
this._fail(
LOG_PREFIX +
"blocked XHR request: " +
(this._kind ? this._kind.path + " (" + this._kind.reason + ")" : this._url)
);
return;
}
var text = bodyToString(body);
if (text && typeof text === "object" && text.unsupported) {
this._fail(LOG_PREFIX + "blocked XHR with a binary body to " + this._url);
return;
}
this._setReadyState(2);
this.dispatchEvent(xhrEvent("loadstart", this));
bridgeFetch(this._kind.apiPath, this._method, text)
.then(function (answer) {
if (self._aborted) return undefined;
self.status = answer.status;
self.statusText = answer.status >= 200 && answer.status < 300 ? "OK" : "Error";
try {
self.responseURL = new URL(self._url, location.href).href;
} catch (_ignored) {
self.responseURL = self._url;
}
self.responseHeaders = {};
answer.headers.forEach(function (value, name) {
self.responseHeaders[String(name).toLowerCase()] = value;
});
if (self.responseType === "json") {
return answer
.text()
.then(function (text) {
self.responseText = "";
try {
self.response = text === "" ? null : JSON.parse(text);
} catch (_parseError) {
self.response = null;
}
})
.then(function () {
self._finish();
});
}
return answer.text().then(function (text) {
self.responseText = text;
self.response = text;
self._finish();
});
})
.catch(function (error) {
if (self._aborted) return undefined;
self._fail(String(error && error.message ? error.message : error));
});
};
ShimXHR.prototype._finish = function () {
this._setReadyState(4);
this.dispatchEvent(xhrEvent("load", this));
this.dispatchEvent(xhrEvent("loadend", this));
};
function installXHR() {
var existing = window.XMLHttpRequest;
// A previous evaluation already installed ours: keep the true native.
var NativeXHR = existing && existing.__inferwayStudioNative ? existing.__inferwayStudioNative : existing;
function Replacement() {
return new ShimXHR();
}
Replacement.prototype = ShimXHR.prototype;
Replacement.__inferwayStudioNative = NativeXHR;
Replacement.__native = NativeXHR;
Replacement.UNSENT = 0;
Replacement.OPENED = 1;
Replacement.HEADERS_RECEIVED = 2;
Replacement.LOADING = 3;
Replacement.DONE = 4;
defineOnWindow("XMLHttpRequest", Replacement);
}
// -------------------------------------------------------------------------
// 6. WebSocket — inert stub for the editor's own socket, throw otherwise
// -------------------------------------------------------------------------
function createEventTarget(host) {
var listeners = Object.create(null);
return {
addEventListener: function (type, listener) {
(listeners[type] || (listeners[type] = [])).push(listener);
},
removeEventListener: function (type, listener) {
var list = listeners[type];
if (!list) return;
var index = list.indexOf(listener);
if (index >= 0) list.splice(index, 1);
},
dispatchEvent: function (event) {
var type = event.type;
var handler = host["on" + type];
if (typeof handler === "function") handler.call(host, event);
var list = (listeners[type] || []).slice();
for (var i = 0; i < list.length; i += 1) list[i].call(host, event);
return true;
},
};
}
function ShimWebSocket(url, protocols) {
var target;
if (!(this instanceof ShimWebSocket)) return new ShimWebSocket(url, protocols);
var kind = classifyWs(url);
if (kind.kind !== "inert") {
stats.blockedWebSockets += 1;
var message = LOG_PREFIX + "blocked WebSocket: " + (kind.path || url) + " (" + kind.reason + ")";
console.error(message);
throw new Error(message);
}
stats.inertWebSockets += 1;
this.url = String(url);
this.readyState = ShimWebSocket.CONNECTING;
this.bufferedAmount = 0;
this.extensions = "";
this.protocol = "";
this.binaryType = "arraybuffer";
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
target = createEventTarget(this);
this.addEventListener = target.addEventListener;
this.removeEventListener = target.removeEventListener;
this.dispatchEvent = target.dispatchEvent;
var self = this;
// "Reports open and never sends anything over the network": the frontend
// stops retrying the real socket, and progress arrives by polling (E8).
setTimeout(function () {
if (self.readyState !== ShimWebSocket.CONNECTING) return;
self.readyState = ShimWebSocket.OPEN;
self.dispatchEvent({ type: "open", target: self });
}, 0);
void protocols;
}
ShimWebSocket.CONNECTING = 0;
ShimWebSocket.OPEN = 1;
ShimWebSocket.CLOSING = 2;
ShimWebSocket.CLOSED = 3;
ShimWebSocket.prototype.CONNECTING = 0;
ShimWebSocket.prototype.OPEN = 1;
ShimWebSocket.prototype.CLOSING = 2;
ShimWebSocket.prototype.CLOSED = 3;
ShimWebSocket.prototype.send = function () {
/* inert: nothing is ever written to a socket */
};
ShimWebSocket.prototype.close = function (code, reason) {
var self = this;
if (this.readyState === ShimWebSocket.CLOSED || this.readyState === ShimWebSocket.CLOSING) return;
this.readyState = ShimWebSocket.CLOSING;
setTimeout(function () {
self.readyState = ShimWebSocket.CLOSED;
self.dispatchEvent({ type: "close", target: self, code: code || 1000, reason: String(reason || "") });
}, 0);
};
function installWebSocket() {
defineOnWindow("WebSocket", ShimWebSocket);
}
// -------------------------------------------------------------------------
// 7. Dialogs — alert / confirm / prompt become in-editor DOM dialogs
// -------------------------------------------------------------------------
var DIALOG_STYLE_ID = "inferway-studio-dialog-style";
function ensureDialogStyle() {
if (document.getElementById(DIALOG_STYLE_ID)) return;
var style = document.createElement("style");
style.id = DIALOG_STYLE_ID;
style.textContent = [
".inferway-studio-backdrop{position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-family:inherit}",
".inferway-studio-dialog{background:#1b1b1f;color:#f2f2f3;border:1px solid #3a3a40;border-radius:10px;padding:16px 18px;max-width:420px;width:calc(100% - 48px);box-shadow:0 12px 40px rgba(0,0,0,.5)}",
".inferway-studio-dialog h2{margin:0 0 8px;font-size:15px;line-height:1.4}",
".inferway-studio-dialog p{margin:0 0 8px;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word}",
".inferway-studio-dialog .inferway-studio-note{color:#ffb4a8}",
".inferway-studio-dialog button{margin-top:6px;padding:6px 14px;border-radius:6px;border:1px solid #4a4a52;background:#2a2a30;color:#f2f2f3;font-size:13px;cursor:pointer}",
".inferway-studio-dialog button:hover{background:#33333a}",
].join("");
(document.head || document.documentElement).appendChild(style);
}
function dismissDialog(element) {
if (element && element.parentNode) element.parentNode.removeChild(element);
}
/**
* Render one dismissible dialog. Returns the element so the caller can
* count it. Every dialog is informational: the decision (cancel) has already
* been returned to the caller by the time this paints.
*/
function showInEditorDialog(title, message, note) {
ensureDialogStyle();
var backdrop = document.createElement("div");
backdrop.className = "inferway-studio-backdrop";
var dialog = document.createElement("div");
dialog.className = "inferway-studio-dialog";
dialog.setAttribute("role", "alertdialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-label", title);
var heading = document.createElement("h2");
heading.textContent = title;
dialog.appendChild(heading);
var body = document.createElement("p");
body.className = "inferway-studio-message";
body.textContent = message === undefined || message === null ? "" : String(message);
dialog.appendChild(body);
if (note) {
var noteElement = document.createElement("p");
noteElement.className = "inferway-studio-note";
noteElement.textContent = note;
dialog.appendChild(noteElement);
}
var button = document.createElement("button");
button.type = "button";
button.textContent = "OK";
button.addEventListener("click", function () {
dismissDialog(backdrop);
});
dialog.appendChild(button);
backdrop.addEventListener("click", function (event) {
if (event.target === backdrop) dismissDialog(backdrop);
});
backdrop.appendChild(dialog);
(document.body || document.documentElement).appendChild(backdrop);
stats.dialogs += 1;
return backdrop;
}
function dialogKeydown(event) {
if (event.key !== "Escape" && event.key !== "Enter") return;
var backdrops = document.querySelectorAll(".inferway-studio-backdrop");
if (backdrops.length > 0) dismissDialog(backdrops[backdrops.length - 1]);
}
/**
* All three are synchronous in JS and asynchronous in a DOM, so the return
* value is decided first — always the non-destructive one:
* alert → undefined (nothing to answer), dialog shown
* confirm → false (cancel), dialog explains the cancellation
* prompt → null (no input), dialog shows the question
*/
function installDialogs() {
defineOnWindow("alert", function (message) {
showInEditorDialog("Notice", message, null);
return undefined;
});
defineOnWindow("confirm", function (message) {
showInEditorDialog(
"Confirmation unavailable",
message,
"The editor runs in a sandbox without native dialogs, so this action was cancelled. Nothing changed."
);
console.warn(LOG_PREFIX + "confirm() cancelled by the shim: " + String(message).slice(0, 200));
return false;
});
defineOnWindow("prompt", function (message, defaultValue) {
showInEditorDialog(
"Input unavailable",
message,
"The editor runs in a sandbox without native dialogs, so nothing was entered. Nothing changed."
);
console.warn(LOG_PREFIX + "prompt() cancelled by the shim: " + String(message).slice(0, 200));
void defaultValue;
return null;
});
listen(document, "keydown", dialogKeydown, true);
}
// -------------------------------------------------------------------------
// 8. Downloads — anchor clicks with `download` go to the parent
// -------------------------------------------------------------------------
function toBase64(bytes) {
var binary = "";
var chunk = 0x8000;
for (var i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return window.btoa(binary);
}
/**
* Read the bytes behind a download href: data: and tracked blob: URLs are
* decoded in the frame (no network, no CSP), `api/…` is read OVER THE
* BRIDGE like every other api request, and only a same-directory static
* asset ever reaches the native fetch — which the URL classifier has
* already confined to this directory.
*/
function readDownloadSource(href, kind) {
var url;
try {
url = new URL(String(href), location.href);
} catch (error) {
return Promise.reject(new Error("unresolved download URL: " + href));
}
var local = localResponse(url);
if (local) {
var responsePromise = local instanceof Promise ? local : Promise.resolve(local);
return responsePromise.then(function (response) {
return response.arrayBuffer().then(function (buffer) {
return {
bytes: new Uint8Array(buffer),
mime: (response.headers.get("content-type") || "application/octet-stream").split(";")[0],
};
});
});
}
if (kind && kind.kind === "bridge") {
// The stashed native fetch below would send a REAL request to the api
// prefix — exactly what requirement 2 forbids. The bridge answers it.
return bridgeFetch(kind.apiPath, "GET", null).then(function (response) {
if (!response.ok) throw new Error("HTTP " + response.status);
return response.arrayBuffer().then(function (buffer) {
return {
bytes: new Uint8Array(buffer),
mime: (response.headers.get("content-type") || "application/octet-stream").split(";")[0],
};
});
});
}
var nativeFetch = nativeFetchForBridge;
if (!nativeFetch) return Promise.reject(new Error(LOG_PREFIX + "no native fetch to read the download with"));
return nativeFetch(href).then(function (response) {
if (!response.ok) throw new Error("HTTP " + response.status);
return response.arrayBuffer().then(function (buffer) {
return {
bytes: new Uint8Array(buffer),
mime: (response.headers.get("content-type") || "application/octet-stream").split(";")[0],
};
});
});
}
/**
* The ONE subresource in upstream `index.html` that lives under `api/` is
* ``. It is not a fetch
* and not an XHR, so the interception above never sees it. The link element
* is dropped the moment it is attached (a MutationObserver runs before any
* style sheet is applied), and every other element that would resolve under
* `api/` is dropped with it: nothing under the api prefix may be loaded as
* a resource either (requirement 2). Everything else is left alone —
* including `` elements: they are not subresources, their click
* is intercepted above, and an `api/…` href on one is read over the bridge.
*/
function installApiSubresourceGuard() {
if (typeof MutationObserver !== "function") return;
var root = document.documentElement;
if (!root) return;
var observer = new MutationObserver(function (records) {
for (var i = 0; i < records.length; i += 1) {
var added = records[i].addedNodes;
for (var j = 0; j < added.length; j += 1) {
var node = added[j];
if (!node || node.nodeType !== 1) continue;
if (node.tagName === "A" && node.hasAttribute("download")) continue;
var attribute = node.getAttribute && (node.getAttribute("href") || node.getAttribute("src"));
if (!attribute) continue;
var kind = classifyUrl(attribute);
if (kind.kind === "bridge") {
console.error(LOG_PREFIX + "blocked the subresource " + kind.path + " under the api prefix");
if (node.parentNode) node.parentNode.removeChild(node);
}
}
}
});
observer.observe(root, { childList: true, subtree: true });
listen(document, "DOMContentLoaded", function () {
observer.disconnect();
});
}
/** Remember every object URL this frame creates so its blob can be read back. */
function installObjectUrlTracking() {
var nativeCreate = URL.createObjectURL;
var nativeRevoke = URL.revokeObjectURL;
if (typeof nativeCreate === "function") {
var replacementCreate = function (value) {
var objectUrl = nativeCreate.call(URL, value);
if (value && typeof value.arrayBuffer === "function") objectUrlBlobs[objectUrl] = value;
return objectUrl;
};
try {
Object.defineProperty(URL, "createObjectURL", { value: replacementCreate, configurable: true, writable: true });
} catch (error) {
try {
URL.createObjectURL = replacementCreate;
} catch (error2) {
console.error(LOG_PREFIX + "could not wrap URL.createObjectURL: " + error2);
}
}
}
if (typeof nativeRevoke === "function") {
var replacementRevoke = function (objectUrl) {
delete objectUrlBlobs[String(objectUrl)];
return nativeRevoke.call(URL, objectUrl);
};
try {
Object.defineProperty(URL, "revokeObjectURL", { value: replacementRevoke, configurable: true, writable: true });
} catch (error) {
try {
URL.revokeObjectURL = replacementRevoke;
} catch (error2) {
console.error(LOG_PREFIX + "could not wrap URL.revokeObjectURL: " + error2);
}
}
}
}
function safeFileName(name) {
var cleaned = String(name || "download").split(/[\\/]/).pop() || "download";
return cleaned.slice(0, 255);
}
function saveViaParent(anchor) {
var href = anchor.getAttribute("href");
var name = safeFileName(anchor.getAttribute("download"));
if (!href) {
console.error(LOG_PREFIX + "blocked download without an href: " + name);
return;
}
var kind = classifyUrl(href);
if (kind.kind === "refuse") {
console.error(LOG_PREFIX + "blocked download of " + kind.path + " (" + kind.reason + ")");
return;
}
var mime = "application/octet-stream";
readDownloadSource(href, kind)
.then(function (file) {
mime = file.mime;
return sendRequest("download", { name: name, mime: mime, data: toBase64(file.bytes) }, REQUEST_TIMEOUT_MS);
})
.then(function (ack) {
if (!ack.ok) {
console.error(LOG_PREFIX + "parent refused the download of " + name + ": " + (ack.error || "no reason"));
return;
}
stats.downloads += 1;
console.warn(LOG_PREFIX + "download handed to the parent: " + name);
})
.catch(function (error) {
console.error(LOG_PREFIX + "download of " + name + " failed: " + (error && error.message ? error.message : error));
});
}
function installDownloadInterception() {
// Capture phase: the default (the browser's own download) is stopped
// before it can start, while the editor's own click handlers still run.
listen(
document,
"click",
function (event) {
var node = event.target;
if (!node || typeof node.closest !== "function") return;
var anchor = node.closest("a[download]");
if (!anchor) return;
event.preventDefault();
saveViaParent(anchor);
},
true
);
}
// -------------------------------------------------------------------------
// 9. Clipboard — writes cross the bridge, reads do not exist here
// -------------------------------------------------------------------------
function clipboardError(message) {
console.error(LOG_PREFIX + message);
if (typeof DOMException === "function") return new DOMException(message, "NotAllowedError");
var error = new Error(message);
error.name = "NotAllowedError";
return error;
}
function refuseClipboardRead(operation) {
stats.blockedClipboardReads += 1;
return Promise.reject(
clipboardError(
"clipboard " +
operation +
" is not available: a sandboxed editor may not read the user's clipboard. Copy still works; paste must be supplied by the host UI."
)
);
}
function installClipboard() {
var implementation = {
writeText: function (text) {
if (typeof text !== "string") {
return Promise.reject(new TypeError(LOG_PREFIX + "clipboard.writeText expects a string"));
}
return sendRequest("clipboard-write", { text: text }, REQUEST_TIMEOUT_MS).then(function (ack) {
if (!ack.ok) throw clipboardError("parent refused the clipboard write: " + (ack.error || "no reason"));
stats.clipboardWrites += 1;
return undefined;
});
},
write: function (items) {
// Only text/plain survives the bridge (the parent handler takes a
// string). An image write is refused loudly rather than dropped.
var list = typeof items === "object" && items !== null ? Array.prototype.slice.call(items) : [];
var textItem = null;
var onlyText = list.length > 0;
for (var i = 0; i < list.length; i += 1) {
var types = list[i] && list[i].types;
var itemTypes = types ? Array.prototype.slice.call(types) : [];
if (itemTypes.length === 1 && itemTypes[0] === "text/plain" && !textItem) textItem = list[i];
else onlyText = false;
}
if (onlyText && textItem && typeof textItem.getType === "function") {
return textItem.getType("text/plain").then(function (blob) {
return blob.text().then(function (text) {
return implementation.writeText(text);
});
});
}
return Promise.reject(
clipboardError(
"clipboard.write is limited to text/plain here; writing files or images to the clipboard is not supported by the editor bridge"
)
);
},
readText: function () {
return refuseClipboardRead("readText");
},
read: function () {
return refuseClipboardRead("read");
},
};
try {
Object.defineProperty(navigator, "clipboard", { value: implementation, configurable: true, enumerable: true });
} catch (error) {
console.error(LOG_PREFIX + "could not replace navigator.clipboard: " + error);
}
}
// -------------------------------------------------------------------------
// 10. Keep the native fetch reachable for our own download reads
// -------------------------------------------------------------------------
function stashNativeFetch() {
var current = window.fetch;
// A previous evaluation of this file already wrapped the native one.
if (current && current.__inferwayStudioNative) {
nativeFetchForBridge = current.__inferwayStudioNative;
return;
}
if (typeof current === "function") nativeFetchForBridge = current.bind(window);
}
// -------------------------------------------------------------------------
// Install, in order, before anything else runs
// -------------------------------------------------------------------------
removeAllPreviousListeners();
loadProtocol();
installStorage();
seedSelectedWorkflow();
installCookieShim();
installMessageListener();
stashNativeFetch();
installObjectUrlTracking();
installFetch();
installXHR();
installWebSocket();
installDialogs();
installDownloadInterception();
installClipboard();
installApiSubresourceGuard();
window.__inferwayStudioListeners = listenerRegistrations;
/**
* Introspection handle — always present, never conditional. It exposes
* counts and the URL classifier so tests and support engineers can read
* what the shim decided; it exposes no message payloads and changes no
* behaviour.
*/
defineOnWindow(
"__inferwayStudioShim",
Object.freeze({
version: SHIM_VERSION,
editorDir: EDITOR_DIR,
protocolUrl: "./inferway-bridge-protocol.js",
classify: classifyUrl,
classifyWs: classifyWs,
stats: stats,
})
);
})();