/
githubmirror
/
amphtml
Обзор
Документация
Войти
/
githubmirror
/
amphtml
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/friendly-iframe-embed.js
818 строк
22 KB
Viktor Szépe
Fix typos in src/ (#39606)
12 авг 2025, 21:35
Не верифицирован
12 авг 2025, 21:35
fca692e
Код
Авторство
О чём код?
import {CommonSignals_Enum} from '#core/constants/common-signals'; import {VisibilityState_Enum} from '#core/constants/visibility-state'; import {Deferred} from '#core/data-structures/promise'; import {Signals} from '#core/data-structures/signals'; import {isDocumentReady} from '#core/document/ready'; import {escapeHtml} from '#core/dom'; import {layoutRectLtwh, moveLayoutRect} from '#core/dom/layout/rect'; import { px, resetStyles, setImportantStyles, setStyle, setStyles, } from '#core/dom/style'; import {rethrowAsync} from '#core/error'; import * as mode from '#core/mode'; import {getWin} from '#core/window'; import {install as installAbortController} from '#polyfills/abort-controller'; import {install as installCustomElements} from '#polyfills/custom-elements'; import {install as installDocContains} from '#polyfills/document-contains'; import {installForChildWin as installIntersectionObserver} from '#polyfills/intersection-observer'; import {installForChildWin as installResizeObserver} from '#polyfills/resize-observer'; import {Services} from '#service'; import {installAmpdocServicesForEmbed} from '#service/core-services'; import {installTimerInEmbedWindow} from '#service/timer-impl'; import {loadPromise} from '#utils/event-helper'; import {dev, devAssert, userAssert} from '#utils/log'; import * as urls from './config/urls'; import {FIE_EMBED_PROP} from './iframe-helper'; import {whenContentIniLoad} from './ini-load'; import {getMode} from './mode'; import { disposeServicesForEmbed, getTopWindow, setParentWindow, } from './service-helpers'; import {installStylesForDoc} from './style-installer'; import {cssText as ampSharedCss} from '../build/ampshared.css'; /** * Parameters used to create the new "friendly iframe" embed. * - html: The complete content of an AMP embed, which is itself an AMP * document. Can include whatever is normally allowed in an AMP document, * except for AMP `<script>` declarations. Those should be passed as an * array of `extensions`. * - extensions: An optional array of AMP extension IDs/versions used in * this embed. * - fonts: An optional array of fonts used in this embed. * * * @typedef {{ * host: (?AmpElement|undefined), * url: string, * html: ?string, * extensions: (?Array<{extensionId: string, extensionVersion: string}>|undefined), * fonts: (?Array<string>|undefined), * skipHtmlMerge: (boolean|undefined), * }} */ export let FriendlyIframeSpec; /** * @type {boolean|undefined} * @visibleForTesting */ let srcdocSupported; /** * @param {boolean|undefined} val * @visibleForTesting */ export function setSrcdocSupportedForTesting(val) { srcdocSupported = val; } /** * @return {function(*): !Promise<*>} */ function getDelayPromiseProducer() { return (val) => new Promise((resolve) => { setTimeout(() => resolve(val), 1); }); } /** * Returns `true` if the Friendly Iframes are supported. * @return {boolean} */ export function isSrcdocSupported() { if (srcdocSupported === undefined) { srcdocSupported = 'srcdoc' in HTMLIFrameElement.prototype; } return srcdocSupported; } /** * Get trusted urls enabled for polyfills. * @return {string} */ export function getFieSafeScriptSrcs() { const cdnBase = getMode().localDev ? 'http://localhost:8000/dist' : urls.cdn; return `${cdnBase}/lts/ ${cdnBase}/rtv/ ${cdnBase}/sw/`; } /** * @param {!Window} win * @param {!Array<{extensionId: string, extensionVersion: string}>} extensions */ export function preloadFriendlyIframeEmbedExtensions(win, extensions) { const extensionsService = Services.extensionsFor(win); // Load any extensions; do not wait on their promises as this // is just to prefetch. extensions.forEach(({extensionId, extensionVersion}) => extensionsService.preloadExtension(extensionId, extensionVersion) ); } /** * Creates the requested "friendly iframe" embed. Returns the promise that * will be resolved as soon as the embed is available. The actual * initialization of the embed will start as soon as the `iframe` is added * to the DOM. * @param {!HTMLIFrameElement} iframe * @param {!Element} container * @param {!FriendlyIframeSpec} spec * @param {function(!Window, ?./service/ampdoc-impl.AmpDoc=)=} opt_preinstallCallback * @return {!Promise<!FriendlyIframeEmbed>} */ export function installFriendlyIframeEmbed( iframe, container, spec, opt_preinstallCallback // TODO(#22733): remove "window" argument. ) { /** @const {!Window} */ const win = getTopWindow(getWin(iframe)); /** @const {!./service/extensions-impl.Extensions} */ const extensionsService = Services.extensionsFor(win); /** @const {!./service/ampdoc-impl.AmpDocService} */ const ampdocService = Services.ampdocServiceFor(win); setStyle(iframe, 'visibility', 'hidden'); iframe.setAttribute('referrerpolicy', 'unsafe-url'); iframe.setAttribute('marginheight', '0'); iframe.setAttribute('marginwidth', '0'); const extensions = spec.extensions || []; // Pre-load extensions. preloadFriendlyIframeEmbedExtensions(win, extensions); const html = spec.skipHtmlMerge ? spec.html : mergeHtml(spec); // Receive the signal when iframe is ready: it's document is formed. iframe.onload = () => { // Chrome does not reflect the iframe readystate. iframe.readyState = 'complete'; }; const registerViolationListener = () => { iframe.contentWindow.addEventListener( 'securitypolicyviolation', (violationEvent) => { dev().warn('FIE', 'security policy violation', violationEvent); } ); }; let loadedPromise; if (isSrcdocSupported()) { iframe.srcdoc = html; loadedPromise = loadPromise(iframe); container.appendChild(iframe); registerViolationListener(); } else { iframe.src = 'about:blank'; container.appendChild(iframe); const childDoc = iframe.contentWindow.document; registerViolationListener(); childDoc.open(); childDoc.write(devAssert(html)); // With document.write, `iframe.onload` arrives almost immediately, thus // we need to wait for child's `window.onload`. loadedPromise = loadPromise(iframe.contentWindow); childDoc.close(); } // Wait for document ready signal. // This is complicated due to crbug.com/649201 on Chrome and a similar issue // on Safari where newly created document's `readyState` immediately equals // `complete`, even though the document itself is not yet available. There's // no other reliable signal for `readyState` in a child window and thus // we have to fallback to polling. let readyPromise; if (isIframeReady(iframe)) { readyPromise = Promise.resolve(); } else { readyPromise = new Promise((resolve) => { /** @const {number} */ const interval = win.setInterval(() => { if (isIframeReady(iframe)) { resolve(); win.clearInterval(interval); } }, /* milliseconds */ 5); // For safety, make sure we definitely stop polling when child doc is // loaded. loadedPromise .catch((error) => { rethrowAsync(error); }) .then(() => { resolve(); win.clearInterval(interval); }); }); } return readyPromise.then(() => { const childWin = /** @type {!Window} */ (iframe.contentWindow); const signals = spec.host && spec.host.signals(); const ampdoc = ampdocService.installFieDoc(spec.url, childWin, {signals}); const embed = new FriendlyIframeEmbed(iframe, spec, loadedPromise, ampdoc); iframe[FIE_EMBED_PROP] = embed; // Window might have been destroyed. if (!childWin.frameElement) { return null; } // Add extensions. return Installers.installExtensionsInEmbed( embed, extensionsService, ampdoc, extensions, opt_preinstallCallback ).then(() => { if (!childWin.frameElement) { return null; } return embed; }); }); } /** * Returns `true` when iframe is ready. * @param {!HTMLIFrameElement} iframe * @return {boolean} */ function isIframeReady(iframe) { // This is complicated due to crbug.com/649201 on Chrome and a similar issue // on Safari where newly created document's `readyState` immediately equals // `complete`, even though the document itself is not yet available. There's // no other reliable signal for `readyState` in a child window and thus // the best way to check is to see the contents of the body. const childDoc = iframe.contentWindow && iframe.contentWindow.document; return !!( childDoc && isDocumentReady(childDoc) && childDoc.body && childDoc.body.firstChild ); } /** * Merges base and fonts into html document. * @param {!FriendlyIframeSpec} spec * @return {string} */ function mergeHtml(spec) { const originalHtml = spec.html; const originalHtmlUp = originalHtml.toUpperCase(); // Find the insertion point. let ip = originalHtmlUp.indexOf('<HEAD'); if (ip != -1) { ip = originalHtmlUp.indexOf('>', ip + 1) + 1; } if (ip == -1) { ip = originalHtmlUp.indexOf('<BODY'); } if (ip == -1) { ip = originalHtmlUp.indexOf('<HTML'); if (ip != -1) { ip = originalHtmlUp.indexOf('>', ip + 1) + 1; } } const result = []; // Preamble. if (ip > 0) { result.push(originalHtml.substring(0, ip)); } // Add <BASE> tag. result.push(`<base href="${escapeHtml(spec.url)}">`); // Load fonts. if (spec.fonts) { spec.fonts.forEach((font) => { result.push( `<link href="${escapeHtml(font)}" rel="stylesheet" type="text/css">` ); }); } const cspScriptSrc = getFieSafeScriptSrcs(); // Load CSP result.push( '<meta http-equiv=Content-Security-Policy ' + `content="script-src ${cspScriptSrc};object-src 'none';child-src 'none'">` ); // Postambule. if (ip > 0) { result.push(originalHtml.substring(ip)); } else { result.push(originalHtml); } return result.join(''); } /** * Exposes `mergeHtml` for testing purposes. * @param {!FriendlyIframeSpec} spec * @return {string} * @visibleForTesting */ export function mergeHtmlForTesting(spec) { return mergeHtml(spec); } /** * A "friendly iframe" embed. This is the iframe that's fully accessible to * the AMP runtime. It's similar to Shadow DOM in many respects, but it also * provides iframe/viewport measurements and enables the use of `vh`, `vw` and * `@media` CSS. * * The friendly iframe is managed by the top-level AMP Runtime. When it's * destroyed, the `destroy` method must be called to free up the shared * resources. * * @visibleForTesting */ export class FriendlyIframeEmbed { /** * @param {!HTMLIFrameElement} iframe * @param {!FriendlyIframeSpec} spec * @param {!Promise} loadedPromise * @param {?./service/ampdoc-impl.AmpDocFie} ampdoc */ constructor(iframe, spec, loadedPromise, ampdoc) { /** @const {!HTMLIFrameElement} */ this.iframe = iframe; /** @const {!Window} */ this.win = /** @type {!Window} */ (iframe.contentWindow); /** @const {?./service/ampdoc-impl.AmpDocFie} */ this.ampdoc = ampdoc; /** @const {!FriendlyIframeSpec} */ this.spec = spec; /** @const {?AmpElement} */ this.host = spec.host || null; /** @const @private {time} */ this.startTime_ = Date.now(); /** @private @const */ this.signals_ = this.ampdoc ? this.ampdoc.signals() : this.host ? this.host.signals() : new Signals(); /** @private @const {!Deferred} */ this.renderComplete_ = new Deferred(); /** @private @const {!Promise} */ this.winLoadedPromise_ = Promise.all([ loadedPromise, this.whenRenderStarted(), ]); if (this.ampdoc) { this.whenRenderComplete().then(() => this.ampdoc.setReady()); } this.win.addEventListener('resize', () => this.handleResize_()); } /** * Ensures that all resources from this iframe have been released. */ destroy() { disposeServicesForEmbed(this.win); if (this.ampdoc) { this.ampdoc.dispose(); } } /** * @return {time} */ getStartTime() { return this.startTime_; } /** * Returns the base URL for the embedded document. * @return {string} */ getUrl() { return this.spec.url; } /** @return {!Signals} */ signals() { return this.signals_; } /** * Returns a promise that will resolve when the embed document is ready. * Notice that this signal coincides with the embed's `render-start`. * @return {!Promise} */ whenRenderStarted() { return this.signals_.whenSignal(CommonSignals_Enum.RENDER_START); } /** * Returns a promise that will resolve when the child window's `onload` event * has been emitted. In friendly iframes this typically only includes font * loading. * @return {!Promise} */ whenWindowLoaded() { return this.winLoadedPromise_; } /** * Returns a promise that will resolve when the initial load of the embed's * content has been completed. * @return {!Promise} */ whenIniLoaded() { return this.signals_.whenSignal(CommonSignals_Enum.INI_LOAD); } /** * Returns a promise that will resolve when all elements have been * transferred into live embed DOM. * @return {!Promise} */ whenRenderComplete() { return this.renderComplete_.promise; } /** * Signal that indicates that all DOM elements have been transferred to live * embed DOM. */ renderCompleted() { this.renderComplete_.resolve(); } /** * Pause the embed. */ pause() { if (this.ampdoc) { this.ampdoc.overrideVisibilityState(VisibilityState_Enum.PAUSED); } } /** * Resume the embed. */ resume() { if (this.ampdoc) { this.ampdoc.overrideVisibilityState(VisibilityState_Enum.VISIBLE); } } /** * @private * @restricted */ startRender_() { if (this.host) { this.host.renderStarted(); } else { this.signals_.signal(CommonSignals_Enum.RENDER_START); } // TODO(ccordry): remove when no-signing launched. if (!this.spec.skipHtmlMerge) { // When not streaming renderStart signal is good enough. this.renderComplete_.resolve(); } // Common signal RENDER_START indicates time to toggle visibility setStyle(this.iframe, 'visibility', ''); if (this.win.document && this.win.document.body) { this.win.document.documentElement.classList.add('i-amphtml-fie'); setStyles(dev().assertElement(this.win.document.body), { opacity: 1, visibility: 'visible', animation: 'none', }); } // Initial load signal signal. let rect; if (this.host) { rect = this.host.getLayoutBox(); } else { rect = layoutRectLtwh( 0, 0, this.win./*OK*/ innerWidth, this.win./*OK*/ innerHeight ); } Promise.all([ this.whenRenderComplete(), whenContentIniLoad(this.ampdoc, this.win, rect), ]).then(() => { this.signals_.signal(CommonSignals_Enum.INI_LOAD); }); } /** * @return {!HTMLBodyElement} * @visibleForTesting */ getBodyElement() { return /** @type {!HTMLBodyElement} */ ( (this.iframe.contentDocument || this.iframe.contentWindow.document).body ); } /** * Force remeasure inside FIE doc when iframe is resized. * @private */ handleResize_() { this.getMutator_().mutateElement( this.win.document.documentElement, () => {} // NOOP. ); } /** * @return {!./service/mutator-interface.MutatorInterface} * @private */ getMutator_() { return Services.mutatorForDoc(this.iframe); } /** * Runs a measure/mutate cycle ensuring that the iframe change is propagated * to the resource manager. * @param {{measure: (function()|undefined), mutate: function()}} task * @return {!Promise} * @private */ measureMutate_(task) { return this.getMutator_().measureMutateElement( this.iframe, task.measure || null, task.mutate ); } /** * @return {!Promise} */ enterFullOverlayMode() { const ampAdParent = dev().assertElement(this.iframe.parentNode); // Security assertion. Otherwise any 3p frame could request lighbox mode. userAssert( ampAdParent.tagName.toLowerCase() == 'amp-ad', 'Only <amp-ad> is allowed to enter lightbox mode.' ); let bodyStyle; return this.measureMutate_({ measure: () => { const rect = this.host ? this.host.getLayoutBox() : this.iframe./*OK*/ getBoundingClientRect(); // Offset by scroll top as iframe will be position: fixed. const dy = -Services.viewportForDoc(this.iframe).getScrollTop(); const {height, left, top, width} = moveLayoutRect(rect, /* dx */ 0, dy); // Offset body by header height to prevent visual jump. bodyStyle = { top: px(top), left: px(left), width: px(width), height: px(height), }; }, mutate: () => { // !important to prevent abuse e.g. box @ ltwh = 0, 0, 0, 0 setImportantStyles(this.iframe, { 'position': 'fixed', 'left': 0, 'right': 0, 'bottom': 0, 'width': '100vw', 'top': 0, 'height': '100vh', }); // We need to override runtime-level !important rules setImportantStyles(this.getBodyElement(), { 'background': 'transparent', 'position': 'absolute', 'bottom': 'auto', 'right': 'auto', // Read during vsync measure phase. 'top': bodyStyle.top, 'left': bodyStyle.left, 'width': bodyStyle.width, 'height': bodyStyle.height, }); }, }); } /** * @return {!Promise} */ leaveFullOverlayMode() { return this.measureMutate_({ mutate: () => { resetStyles(this.iframe, [ 'position', 'left', 'right', 'top', 'bottom', 'width', 'height', ]); // we're not resetting background here as we need to set it to // transparent permanently. resetStyles(this.getBodyElement(), [ 'position', 'top', 'left', 'width', 'height', 'bottom', 'right', ]); }, }); } } /** * Install polyfills in the child window (friendly iframe). * @param {!Window} parentWin * @param {!Window} childWin */ function installPolyfillsInChildWindow(parentWin, childWin) { if (!mode.isEsm()) { installDocContains(childWin); installCustomElements(childWin, class {}); } // The anonymous class parameter allows us to detect native classes vs // transpiled classes. if (!IS_SXG) { installIntersectionObserver(parentWin, childWin); installResizeObserver(parentWin, childWin); installAbortController(childWin); } } /** * Static installers that can be easily stubbed for tests. * @visibleForTesting */ export class Installers { /** * Install extensions in the child window (friendly iframe). The pre-install * callback, if specified, is executed after polyfills have been configured * but before the first extension is installed. * @param {!FriendlyIframeEmbed} embed * @param {!./service/extensions-impl.Extensions} extensionsService * @param {!./service/ampdoc-impl.AmpDocFie} ampdoc * @param {!Array<{extensionId: string, extensionVersion: string}>} extensions * @param {function(!Window, ?./service/ampdoc-impl.AmpDoc=)|undefined} preinstallCallback * @param {function(!Promise)=} opt_installComplete * @return {!Promise} */ static installExtensionsInEmbed( embed, extensionsService, ampdoc, extensions, preinstallCallback, opt_installComplete ) { const childWin = ampdoc.win; const parentWin = getWin(childWin.frameElement); setParentWindow(childWin, parentWin); const getDelayPromise = getDelayPromiseProducer(); return getDelayPromise(undefined) .then(() => { // Install necessary polyfills. installPolyfillsInChildWindow(parentWin, childWin); }) .then(getDelayPromise) .then(() => { if (mode.isEsm()) { // TODO: This is combined (ampdoc + shared), not just shared // const css = parentWin.document.querySelector('style[amp-runtime]') // .textContent; installStylesForDoc( ampdoc, ampSharedCss, /* callback */ null, /* opt_isRuntimeCss */ true, /* opt_ext */ 'amp-runtime' ); } else { // Install runtime styles. installStylesForDoc( ampdoc, ampSharedCss, /* callback */ null, /* opt_isRuntimeCss */ true, /* opt_ext */ 'amp-runtime' ); } }) .then(getDelayPromise) .then(() => { if (!childWin.frameElement) { return; } // Run pre-install callback. if (preinstallCallback) { preinstallCallback(ampdoc.win, ampdoc); } }) .then(getDelayPromise) .then(() => { if (!childWin.frameElement) { return; } // Install embeddable standard services. Installers.installStandardServicesInEmbed(ampdoc); }) .then(getDelayPromise) .then(() => { if (!childWin.frameElement) { return; } extensionsService.preinstallEmbed(ampdoc, extensions); }) .then(getDelayPromise) .then(() => { if (!childWin.frameElement) { return; } // Ready to be shown. embed.startRender_(); }) .then(getDelayPromise) .then(() => { if (!childWin.frameElement) { return; } // Intentionally do not wait for the full installation to complete. // It's enough of initialization done to return the embed. const promise = extensionsService.installExtensionsInDoc( ampdoc, extensions ); ampdoc.setExtensionsKnown(); if (opt_installComplete) { opt_installComplete(promise); } }); } /** * Adopt predefined core services for the embedded ampdoc (friendly iframe). * @param {!./service/ampdoc-impl.AmpDoc} ampdoc */ static installStandardServicesInEmbed(ampdoc) { installTimerInEmbedWindow(ampdoc.win); installAmpdocServicesForEmbed(ampdoc); } }