From a6569eae6fa5c0c71c3ceeae16f8b0e779781251 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 2 Oct 2026 23:28:04 +0530 Subject: [PATCH 1/7] fix(livepreview): harden preview channel and preserve unfinished pages Capture channel dependencies before page scripts run and seal private globals after runtime initialization. Inject the runtime before page scripts and unfinished markup while retaining leading doctypes. Add injection-order and unfinished-page regression coverage. Verified the full 285-spec livepreview category on Linux Edge, Linux Electron, Windows Tauri and macOS Tauri, plus targeted HTML unit suites. --- .../BrowserScripts/LiveDevProtocolRemote.js | 95 ++++++++++++++----- .../LivePreviewTransportRemote.js | 57 ++++++++--- .../BrowserScripts/RemoteFunctions.js | 3 +- .../documents/LiveHTMLDocument.js | 22 ++++- .../language/HTMLInstrumentation.js | 54 +++++++---- .../protocol/LiveDevProtocol.js | 22 ++++- test/spec/HTMLInstrumentation-test.js | 95 +++++++++++++++++++ test/spec/LiveDevelopmentMultiBrowser-test.js | 29 ++++++ 8 files changed, 316 insertions(+), 61 deletions(-) diff --git a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js index eaf420dc83..5dcf88a111 100644 --- a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js @@ -35,6 +35,43 @@ // send(msgStr) - sends the given message string over the transport. var transport = global._Brackets_LiveDev_Transport; + // The page's own scripts run after this file and may patch built-ins to read or rewrite what goes + // to and from the editor, so everything on that path uses the originals, captured here. The + // editor's Runtime.evaluate scripts run through the real eval, called indirectly so they see only + // the global scope, as they would from the page: never this closure's transport, comm or the CSS + // domain object that shadows window.CSS here. + const _globalEval = global.eval; + const _parse = JSON.parse; + const _stringify = JSON.stringify; + const _Promise = global.Promise; + const _setTimeout = global.setTimeout; + const _clearTimeout = global.clearTimeout; + + /** + * Append without Array.prototype.push, which a page could patch to collect the item. + * @param {Array} arr Destination array. + * @param {*} item Item to append. + */ + function _arrayPush(arr, item) { + arr[arr.length] = item; + } + + /** + * Copy nonmatching items without exposing the array to a page-patched filter. + * @param {Array} arr Source array. + * @param {function(*): boolean} predicate Returns true for items to remove. + * @return {Array} The remaining items, in order. + */ + function _arrayWithout(arr, predicate) { + const kept = []; + for (let i = 0; i < arr.length; i++) { + if (!predicate(arr[i])) { + _arrayPush(kept, arr[i]); + } + } + return kept; + } + /** * Manage messaging between Editor and Browser at the protocol layer. * Handle messages that arrives through the current transport and dispatch them @@ -68,16 +105,15 @@ if (msgHandlers && msgHandlers.length > 0) { // invoke handlers with the received message - msgHandlers.forEach(function (handler) { + // A plain loop: a patched Array.prototype.forEach would be handed the message. + for (let i = 0; i < msgHandlers.length; i++) { try { // TODO: check which context should be used to call handlers here. - handler(msg); - return; + msgHandlers[i](msg); } catch (e) { console.error("[Brackets LiveDev] Error executing a handler for " + msg.method, e.stack); - return; } - }); + } } else { // no subscribers, ignore it. // TODO: any other default handling? (eg. specific respond, trigger as a generic event, etc.); @@ -115,8 +151,7 @@ //initialize array this.handlers[method] = []; } - // add handler to the stack - this.handlers[method].push(handler); + _arrayPush(this.handlers[method], handler); }, /** @@ -124,7 +159,7 @@ * @param {string} msgStr Message to be sent. */ send: function (msgStr) { - transport.send(JSON.stringify(msgStr)); + transport.send(_stringify(msgStr)); } }; @@ -140,9 +175,9 @@ evaluate: function (msg) { // an unanswered request leaves the editor side waiting forever try { - var result = eval(msg.params.expression); + var result = _globalEval(msg.params.expression); MessageBroker.respond(msg, { - result: JSON.stringify(result) // TODO: in original protocol this is an object handle + result: _stringify(result) // TODO: in original protocol this is an object handle }); } catch (e) { console.error("[Brackets LiveDev] Runtime.evaluate failed", e); @@ -349,7 +384,7 @@ * @param {string} msgStr The protocol message as stringified JSON. */ message: function (msgStr) { - const msg = JSON.parse(msgStr); + const msg = _parse(msgStr); _setPCommReady(); if(msg && typeof msg === "object" && msg.method === "PhoenixComm.execLPFn") { _onLPFnTrigger(msg.fnName, msg.params); @@ -397,7 +432,8 @@ let currentFnExecID = 1; let lpCommReady = false; - const pendingExecPromises = new Map(); + // A page-patched Map.prototype.set must not receive the promise's resolve/reject callbacks. + const pendingExecPromises = Object.create(null); let queuedExecRequests = []; // array of { fnName, paramObj, fnExecID } // A pending execPhoenixFn is only ever settled by a response message, so if // the editor disconnects mid-call the promise would hang forever (and any @@ -414,7 +450,7 @@ if (lpCommReady) { MessageBroker.send(payload); } else { - queuedExecRequests.push(payload); + _arrayPush(queuedExecRequests, payload); } } @@ -437,21 +473,25 @@ } registeredPhoenixCommFns[fnName] = fn; }, + /** + * Request an editor function, rejecting if no response arrives before the timeout. + * @param {string} fnName Registered editor function name. + * @param {*} paramObj Function parameters. + * @return {Promise<*>} The editor's response. + */ execPhoenixFn: function (fnName, paramObj) { - return new Promise((resolve, reject) => { + return new _Promise((resolve, reject) => { const fnExecID = currentFnExecID++; - const timer = setTimeout(function () { - if (pendingExecPromises.has(fnExecID)) { - pendingExecPromises.delete(fnExecID); + const timer = _setTimeout(function () { + if (pendingExecPromises[fnExecID]) { + delete pendingExecPromises[fnExecID]; // if still queued (comm never became ready), drop it so a // late flush doesn't execute a call the caller saw fail - queuedExecRequests = queuedExecRequests.filter(function (req) { - return req.fnExecID !== fnExecID; - }); + queuedExecRequests = _arrayWithout(queuedExecRequests, req => req.fnExecID === fnExecID); reject(new Error(`execPhoenixFn timed out: ${fnName}`)); } }, PHOENIX_FN_TIMEOUT_MS); - pendingExecPromises.set(fnExecID, { resolve, reject, timer }); + pendingExecPromises[fnExecID] = { resolve, reject, timer }; _sendOrQueueExec({ execFnName: fnName, paramObj, @@ -468,17 +508,24 @@ PhoenixComm.registerLpFn("PH_LP_COMM_READY", _setPCommReady); + /** + * Settle a pending editor request and clear its timeout; ignore late or unknown replies. + * @param {string} fnName Editor function name, used for diagnostics. + * @param {number} fnExecID Request identifier. + * @param {*} resolveWith Successful result. + * @param {*} rejectWith Error returned by the editor, if any. + */ function _onPhoenixExecResponse(fnName, fnExecID, resolveWith, rejectWith) { - const pendingPromise = pendingExecPromises.get(fnExecID); + const pendingPromise = pendingExecPromises[fnExecID]; if(!pendingPromise) { // already settled (e.g. by the timeout) or an unknown id — bail // rather than dereference undefined and throw in the message handler console.error(`execPhoenixFn: No response promise found! for ${fnName}: ${fnExecID}`); return; } - pendingExecPromises.delete(fnExecID); + delete pendingExecPromises[fnExecID]; if (pendingPromise.timer) { - clearTimeout(pendingPromise.timer); + _clearTimeout(pendingPromise.timer); } if(rejectWith) { pendingPromise.reject(rejectWith); diff --git a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js index 6bb986b0b4..682a71f9e6 100644 --- a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js @@ -97,12 +97,41 @@ const TRANSPORT_CONFIG={}; //Replace dynamic section end + // The page's own scripts run after this one and may patch built-ins to read or rewrite what goes + // to and from the editor. The channel uses the originals, captured here before any of them runs. + const _apply = Reflect.apply; + const _workerPostMessage = Worker.prototype.postMessage; + const _messageEventData = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data").get; + const _stringify = JSON.stringify; + const _console = global.console; + const _consoleLog = _console.log; + + /** + * Log only when enabled by the editor, using the original console method. + * @param {...*} args Values to log. + */ function _debugLog(...args) { - if(window.LIVE_PREVIEW_DEBUG_ENABLED) { - console.log(...args); + if (TRANSPORT_CONFIG.LIVE_PREVIEW_DEBUG_ENABLED) { + _apply(_consoleLog, _console, args); } } + /** + * Send data through the captured Worker method. + * @param {Object} data Worker message. + */ + function _postToWorker(data) { + _apply(_workerPostMessage, worker, [data]); + } + /** + * Read worker data without invoking a page-patched getter. + * @param {MessageEvent} event Worker event. + * @return {*} The event payload. + */ + function _eventData(event) { + return _apply(_messageEventData, event, []); + } + function createLRU(max = 100) { const map = new Map(); @@ -139,9 +168,10 @@ const worker = new Worker(TRANSPORT_CONFIG.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME); let _workerMessageProcessor; worker.onmessage = (event) => { - const type = event.data.type; + const data = _eventData(event); + const type = data.type; switch (type) { - case 'REDIRECT_PAGE': location.href = event.data.URL; break; + case 'REDIRECT_PAGE': location.href = data.URL; break; default: if(_workerMessageProcessor){ return _workerMessageProcessor(event); @@ -151,7 +181,7 @@ }; // message channel to phoenix connect on load itself. The channel id is injected from phoenix // via LivePreviewTransport.js while serving the instrumented html file - worker.postMessage({ + _postToWorker({ type: "setupPhoenixComm", livePreviewDebugModeEnabled: TRANSPORT_CONFIG.LIVE_PREVIEW_DEBUG_ENABLED, broadcastChannel: TRANSPORT_CONFIG.LIVE_PREVIEW_BROADCAST_CHANNEL_ID, // in browser this will be present, but not in tauri @@ -159,7 +189,7 @@ clientID }); function _postLivePreviewMessage(message) { - worker.postMessage({type: "livePreview", message}); + _postToWorker({type: "livePreview", message}); } let sentTitle, sentFavIconURL; @@ -192,7 +222,7 @@ if(!base64){ base64 = "favicon.ico"; } - worker.postMessage({ + _postToWorker({ type: "updateTitleIcon", faviconBase64: base64 }); @@ -201,7 +231,7 @@ if(sentTitle!== document.title) { sentTitle = document.title; - worker.postMessage({ + _postToWorker({ type: "updateTitleIcon", title: document.title }); @@ -239,18 +269,19 @@ // Listen to the response _workerMessageProcessor = (event) => { + const data = _eventData(event); // Print the result - _debugLog("Live Preview: Browser received event from Phoenix: ", JSON.stringify(event.data)); - const type = event.data.type; + _debugLog("Live Preview: Browser received event from Phoenix: ", _stringify(data)); + const type = data.type; switch (type) { case 'BROWSER_CONNECT': break; // do nothing. This is a loopback message from another live preview tab case 'BROWSER_MESSAGE': break; // do nothing. This is a loopback message from another live preview tab case 'BROWSER_CLOSE': break; // do nothing. This is a loopback message from another live preview tab case 'MESSAGE_FROM_PHOENIX': if (self._callbacks && self._callbacks.message) { - const clientIDs = event.data.clientIDs, - message = event.data.message, - messageID = event.data.messageID; + const clientIDs = data.clientIDs, + message = data.message, + messageID = data.messageID; if(messageID && processedMessageIDs.has(messageID)){ return; // we have already processed this message. } diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 9c62170e54..df45119ced 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -22,7 +22,8 @@ function RemoteFunctions(config = {}) { // this is just a test function here to check if live preview. fn call is working correctly. console.log("Hello World", param); }); - const MessageBroker = window._Brackets_MessageBroker; // to be used by plugins. + // Injected plugins share this closure; the window property is removed after injection. + const MessageBroker = window._Brackets_MessageBroker; const SHARED_STATE = { __description: "Use this to keep shared state for Live Preview Edit instead of window.*", diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js index 195e11c444..7c5243ba14 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js +++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js @@ -121,7 +121,22 @@ define(function (require, exports, module) { }; /** - * Returns the instrumented version of the file. + * Insert the runtime before unfinished markup, preserving a leading doctype's parsing mode. + * Only complete comments and a doctype may precede the script; partial markup must + * follow it so an unfinished attribute, comment or raw-text element cannot swallow the runtime. + * @param {string} html Uninstrumentable source text. + * @param {string} remoteScript Blocking runtime script markup. + * @return {string} Source text with the runtime inserted before page content. + */ + function _injectIntoUnfinishedHTML(html, remoteScript) { + // HTML ends a doctype at the first > even inside an unfinished quoted identifier. + const preamble = /^(?:\uFEFF|[\t\n\f\r ]|b">