Browse Source

feat: add WebContents.ipc (#34959)

Jeremy Rose 2 years ago
parent
commit
6d859dcd7f

+ 33 - 0
docs/api/web-contents.md

@@ -862,6 +862,8 @@ Returns:
 
 Emitted when the renderer process sends an asynchronous message via `ipcRenderer.send()`.
 
+See also [`webContents.ipc`](#contentsipc-readonly), which provides an [`IpcMain`](ipc-main.md)-like interface for responding to IPC messages specifically from this WebContents.
+
 #### Event: 'ipc-message-sync'
 
 Returns:
@@ -872,6 +874,8 @@ Returns:
 
 Emitted when the renderer process sends a synchronous message via `ipcRenderer.sendSync()`.
 
+See also [`webContents.ipc`](#contentsipc-readonly), which provides an [`IpcMain`](ipc-main.md)-like interface for responding to IPC messages specifically from this WebContents.
+
 #### Event: 'preferred-size-changed'
 
 Returns:
@@ -1985,6 +1989,35 @@ This corresponds to the [animationPolicy][] accessibility feature in Chromium.
 
 ### Instance Properties
 
+#### `contents.ipc` _Readonly_
+
+An [`IpcMain`](ipc-main.md) scoped to just IPC messages sent from this
+WebContents.
+
+IPC messages sent with `ipcRenderer.send`, `ipcRenderer.sendSync` or
+`ipcRenderer.postMessage` will be delivered in the following order:
+
+1. `contents.on('ipc-message')`
+2. `contents.mainFrame.on(channel)`
+3. `contents.ipc.on(channel)`
+4. `ipcMain.on(channel)`
+
+Handlers registered with `invoke` will be checked in the following order. The
+first one that is defined will be called, the rest will be ignored.
+
+1. `contents.mainFrame.handle(channel)`
+2. `contents.handle(channel)`
+3. `ipcMain.handle(channel)`
+
+A handler or event listener registered on the WebContents will receive IPC
+messages sent from any frame, including child frames. In most cases, only the
+main frame can send IPC messages. However, if the `nodeIntegrationInSubFrames`
+option is enabled, it is possible for child frames to send IPC messages also.
+In that case, handlers should check the `senderFrame` property of the IPC event
+to ensure that the message is coming from the expected frame. Alternatively,
+register handlers on the appropriate frame directly using the
+[`WebFrameMain.ipc`](web-frame-main.md#frameipc-readonly) interface.
+
 #### `contents.audioMuted`
 
 A `boolean` property that determines whether this page is muted.

+ 25 - 0
docs/api/web-frame-main.md

@@ -140,6 +140,31 @@ ipcRenderer.on('port', (e, msg) => {
 
 ### Instance Properties
 
+#### `frame.ipc` _Readonly_
+
+An [`IpcMain`](ipc-main.md) instance scoped to the frame.
+
+IPC messages sent with `ipcRenderer.send`, `ipcRenderer.sendSync` or
+`ipcRenderer.postMessage` will be delivered in the following order:
+
+1. `contents.on('ipc-message')`
+2. `contents.mainFrame.on(channel)`
+3. `contents.ipc.on(channel)`
+4. `ipcMain.on(channel)`
+
+Handlers registered with `invoke` will be checked in the following order. The
+first one that is defined will be called, the rest will be ignored.
+
+1. `contents.mainFrame.handle(channel)`
+2. `contents.handle(channel)`
+3. `ipcMain.handle(channel)`
+
+In most cases, only the main frame of a WebContents can send or receive IPC
+messages. However, if the `nodeIntegrationInSubFrames` option is enabled, it is
+possible for child frames to send and receive IPC messages also. The
+[`WebContents.ipc`](web-contents.md#contentsipc-readonly) interface may be more
+convenient when `nodeIntegrationInSubFrames` is not enabled.
+
 #### `frame.url` _Readonly_
 
 A `string` representing the current URL of the frame.

+ 0 - 3
lib/browser/api/ipc-main.ts

@@ -2,7 +2,4 @@ import { IpcMainImpl } from '@electron/internal/browser/ipc-main-impl';
 
 const ipcMain = new IpcMainImpl();
 
-// Do not throw exception when channel name is "error".
-ipcMain.on('error', () => {});
-
 export default ipcMain;

+ 23 - 3
lib/browser/api/web-contents.ts

@@ -9,12 +9,15 @@ import { ipcMainInternal } from '@electron/internal/browser/ipc-main-internal';
 import * as ipcMainUtils from '@electron/internal/browser/ipc-main-internal-utils';
 import { MessagePortMain } from '@electron/internal/browser/message-port-main';
 import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
+import { IpcMainImpl } from '@electron/internal/browser/ipc-main-impl';
 
 // session is not used here, the purpose is to make sure session is initialized
 // before the webContents module.
 // eslint-disable-next-line
 session
 
+const webFrameMainBinding = process._linkedBinding('electron_browser_web_frame_main');
+
 let nextId = 0;
 const getNextId = function () {
   return ++nextId;
@@ -556,6 +559,12 @@ WebContents.prototype._init = function () {
 
   this._windowOpenHandler = null;
 
+  const ipc = new IpcMainImpl();
+  Object.defineProperty(this, 'ipc', {
+    get () { return ipc; },
+    enumerable: true
+  });
+
   // Dispatch IPC messages to the ipc module.
   this.on('-ipc-message' as any, function (this: Electron.WebContents, event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) {
     addSenderFrameToEvent(event);
@@ -564,6 +573,9 @@ WebContents.prototype._init = function () {
     } else {
       addReplyToEvent(event);
       this.emit('ipc-message', event, channel, ...args);
+      const maybeWebFrame = webFrameMainBinding.fromIdOrNull(event.processId, event.frameId);
+      maybeWebFrame && maybeWebFrame.ipc.emit(channel, event, ...args);
+      ipc.emit(channel, event, ...args);
       ipcMain.emit(channel, event, ...args);
     }
   });
@@ -575,8 +587,10 @@ WebContents.prototype._init = function () {
       console.error(`Error occurred in handler for '${channel}':`, error);
       event.sendReply({ error: error.toString() });
     };
-    const target = internal ? ipcMainInternal : ipcMain;
-    if ((target as any)._invokeHandlers.has(channel)) {
+    const maybeWebFrame = webFrameMainBinding.fromIdOrNull(event.processId, event.frameId);
+    const targets: (ElectronInternal.IpcMainInternal| undefined)[] = internal ? [ipcMainInternal] : [maybeWebFrame && maybeWebFrame.ipc, ipc, ipcMain];
+    const target = targets.find(target => target && (target as any)._invokeHandlers.has(channel));
+    if (target) {
       (target as any)._invokeHandlers.get(channel)(event, ...args);
     } else {
       event._throw(`No handler registered for '${channel}'`);
@@ -590,10 +604,13 @@ WebContents.prototype._init = function () {
       ipcMainInternal.emit(channel, event, ...args);
     } else {
       addReplyToEvent(event);
-      if (this.listenerCount('ipc-message-sync') === 0 && ipcMain.listenerCount(channel) === 0) {
+      const maybeWebFrame = webFrameMainBinding.fromIdOrNull(event.processId, event.frameId);
+      if (this.listenerCount('ipc-message-sync') === 0 && ipc.listenerCount(channel) === 0 && ipcMain.listenerCount(channel) === 0 && (!maybeWebFrame || maybeWebFrame.ipc.listenerCount(channel) === 0)) {
         console.warn(`WebContents #${this.id} called ipcRenderer.sendSync() with '${channel}' channel without listeners.`);
       }
       this.emit('ipc-message-sync', event, channel, ...args);
+      maybeWebFrame && maybeWebFrame.ipc.emit(channel, event, ...args);
+      ipc.emit(channel, event, ...args);
       ipcMain.emit(channel, event, ...args);
     }
   });
@@ -601,6 +618,9 @@ WebContents.prototype._init = function () {
   this.on('-ipc-ports' as any, function (event: Electron.IpcMainEvent, internal: boolean, channel: string, message: any, ports: any[]) {
     addSenderFrameToEvent(event);
     event.ports = ports.map(p => new MessagePortMain(p));
+    ipc.emit(channel, event, message);
+    const maybeWebFrame = webFrameMainBinding.fromIdOrNull(event.processId, event.frameId);
+    maybeWebFrame && maybeWebFrame.ipc.emit(channel, event, message);
     ipcMain.emit(channel, event, message);
   });
 

+ 9 - 0
lib/browser/api/web-frame-main.ts

@@ -1,7 +1,16 @@
 import { MessagePortMain } from '@electron/internal/browser/message-port-main';
+import { IpcMainImpl } from '@electron/internal/browser/ipc-main-impl';
 
 const { WebFrameMain, fromId } = process._linkedBinding('electron_browser_web_frame_main');
 
+Object.defineProperty(WebFrameMain.prototype, 'ipc', {
+  get () {
+    const ipc = new IpcMainImpl();
+    Object.defineProperty(this, 'ipc', { value: ipc });
+    return ipc;
+  }
+});
+
 WebFrameMain.prototype.send = function (channel, ...args) {
   if (typeof channel !== 'string') {
     throw new Error('Missing required channel argument');

+ 7 - 0
lib/browser/ipc-main-impl.ts

@@ -4,6 +4,13 @@ import { IpcMainInvokeEvent } from 'electron/main';
 export class IpcMainImpl extends EventEmitter {
   private _invokeHandlers: Map<string, (e: IpcMainInvokeEvent, ...args: any[]) => void> = new Map();
 
+  constructor () {
+    super();
+
+    // Do not throw exception when channel name is "error".
+    this.on('error', () => {});
+  }
+
   handle: Electron.IpcMain['handle'] = (method, fn) => {
     if (this._invokeHandlers.has(method)) {
       throw new Error(`Attempted to register a second handler for '${method}'`);

+ 0 - 3
lib/browser/ipc-main-internal.ts

@@ -1,6 +1,3 @@
 import { IpcMainImpl } from '@electron/internal/browser/ipc-main-impl';
 
 export const ipcMainInternal = new IpcMainImpl() as ElectronInternal.IpcMainInternal;
-
-// Do not throw exception when channel name is "error".
-ipcMainInternal.on('error', () => {});

+ 27 - 0
shell/browser/api/electron_api_web_frame_main.cc

@@ -362,6 +362,18 @@ gin::Handle<WebFrameMain> WebFrameMain::From(v8::Isolate* isolate,
   return handle;
 }
 
+// static
+gin::Handle<WebFrameMain> WebFrameMain::FromOrNull(
+    v8::Isolate* isolate,
+    content::RenderFrameHost* rfh) {
+  if (rfh == nullptr)
+    return gin::Handle<WebFrameMain>();
+  auto* web_frame = FromRenderFrameHost(rfh);
+  if (web_frame)
+    return gin::CreateHandle(isolate, web_frame);
+  return gin::Handle<WebFrameMain>();
+}
+
 // static
 v8::Local<v8::ObjectTemplate> WebFrameMain::FillObjectTemplate(
     v8::Isolate* isolate,
@@ -409,6 +421,20 @@ v8::Local<v8::Value> FromID(gin_helper::ErrorThrower thrower,
   return WebFrameMain::From(thrower.isolate(), rfh).ToV8();
 }
 
+v8::Local<v8::Value> FromIDOrNull(gin_helper::ErrorThrower thrower,
+                                  int render_process_id,
+                                  int render_frame_id) {
+  if (!electron::Browser::Get()->is_ready()) {
+    thrower.ThrowError("WebFrameMain is available only after app ready");
+    return v8::Null(thrower.isolate());
+  }
+
+  auto* rfh =
+      content::RenderFrameHost::FromID(render_process_id, render_frame_id);
+
+  return WebFrameMain::FromOrNull(thrower.isolate(), rfh).ToV8();
+}
+
 void Initialize(v8::Local<v8::Object> exports,
                 v8::Local<v8::Value> unused,
                 v8::Local<v8::Context> context,
@@ -417,6 +443,7 @@ void Initialize(v8::Local<v8::Object> exports,
   gin_helper::Dictionary dict(isolate, exports);
   dict.Set("WebFrameMain", WebFrameMain::GetConstructor(context));
   dict.SetMethod("fromId", &FromID);
+  dict.SetMethod("fromIdOrNull", &FromIDOrNull);
 }
 
 }  // namespace

+ 3 - 0
shell/browser/api/electron_api_web_frame_main.h

@@ -44,6 +44,9 @@ class WebFrameMain : public gin::Wrappable<WebFrameMain>,
   static gin::Handle<WebFrameMain> From(
       v8::Isolate* isolate,
       content::RenderFrameHost* render_frame_host);
+  static gin::Handle<WebFrameMain> FromOrNull(
+      v8::Isolate* isolate,
+      content::RenderFrameHost* render_frame_host);
   static WebFrameMain* FromFrameTreeNodeId(int frame_tree_node_id);
   static WebFrameMain* FromRenderFrameHost(
       content::RenderFrameHost* render_frame_host);

+ 199 - 3
spec-main/api-ipc-spec.ts

@@ -3,8 +3,13 @@ import { expect } from 'chai';
 import { BrowserWindow, ipcMain, IpcMainInvokeEvent, MessageChannelMain, WebContents } from 'electron/main';
 import { closeAllWindows } from './window-helpers';
 import { emittedOnce } from './events-helpers';
+import { defer } from './spec-helpers';
+import * as path from 'path';
+import * as http from 'http';
+import { AddressInfo } from 'net';
 
 const v8Util = process._linkedBinding('electron_common_v8_util');
+const fixturesPath = path.resolve(__dirname, 'fixtures');
 
 describe('ipc module', () => {
   describe('invoke', () => {
@@ -90,7 +95,7 @@ describe('ipc module', () => {
     });
 
     it('throws an error when invoking a handler that was removed', async () => {
-      ipcMain.handle('test', () => {});
+      ipcMain.handle('test', () => { });
       ipcMain.removeHandler('test');
       const done = new Promise<void>(resolve => ipcMain.once('result', (e, arg) => {
         expect(arg.error).to.match(/No handler registered/);
@@ -101,9 +106,9 @@ describe('ipc module', () => {
     });
 
     it('forbids multiple handlers', async () => {
-      ipcMain.handle('test', () => {});
+      ipcMain.handle('test', () => { });
       try {
-        expect(() => { ipcMain.handle('test', () => {}); }).to.throw(/second handler/);
+        expect(() => { ipcMain.handle('test', () => { }); }).to.throw(/second handler/);
       } finally {
         ipcMain.removeHandler('test');
       }
@@ -563,4 +568,195 @@ describe('ipc module', () => {
     generateTests('WebContents.postMessage', contents => contents.postMessage.bind(contents));
     generateTests('WebFrameMain.postMessage', contents => contents.mainFrame.postMessage.bind(contents.mainFrame));
   });
+
+  describe('WebContents.ipc', () => {
+    afterEach(closeAllWindows);
+
+    it('receives ipc messages sent from the WebContents', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
+      const [, num] = await emittedOnce(w.webContents.ipc, 'test');
+      expect(num).to.equal(42);
+    });
+
+    it('receives sync-ipc messages sent from the WebContents', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.ipc.on('test', (event, arg) => {
+        event.returnValue = arg * 2;
+      });
+      const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.sendSync(\'test\', 42)');
+      expect(result).to.equal(42 * 2);
+    });
+
+    it('receives postMessage messages sent from the WebContents, w/ MessagePorts', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.postMessage(\'test\', null, [(new MessageChannel).port1])');
+      const [event] = await emittedOnce(w.webContents.ipc, 'test');
+      expect(event.ports.length).to.equal(1);
+    });
+
+    it('handles invoke messages sent from the WebContents', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.ipc.handle('test', (_event, arg) => arg * 2);
+      const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
+      expect(result).to.equal(42 * 2);
+    });
+
+    it('cascades to ipcMain', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      let gotFromIpcMain = false;
+      const ipcMainReceived = new Promise<void>(resolve => ipcMain.on('test', () => { gotFromIpcMain = true; resolve(); }));
+      const ipcReceived = new Promise<boolean>(resolve => w.webContents.ipc.on('test', () => { resolve(gotFromIpcMain); }));
+      defer(() => ipcMain.removeAllListeners('test'));
+      w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
+
+      // assert that they are delivered in the correct order
+      expect(await ipcReceived).to.be.false();
+      await ipcMainReceived;
+    });
+
+    it('overrides ipcMain handlers', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.ipc.handle('test', (_event, arg) => arg * 2);
+      ipcMain.handle('test', () => { throw new Error('should not be called'); });
+      defer(() => ipcMain.removeHandler('test'));
+      const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
+      expect(result).to.equal(42 * 2);
+    });
+
+    it('falls back to ipcMain handlers', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      ipcMain.handle('test', (_event, arg) => { return arg * 2; });
+      defer(() => ipcMain.removeHandler('test'));
+      const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
+      expect(result).to.equal(42 * 2);
+    });
+
+    it('receives ipcs from child frames', async () => {
+      const server = http.createServer((req, res) => {
+        res.setHeader('content-type', 'text/html');
+        res.end('');
+      });
+      await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
+      const port = (server.address() as AddressInfo).port;
+      defer(() => {
+        server.close();
+      });
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegrationInSubFrames: true, preload: path.resolve(fixturesPath, 'preload-expose-ipc.js') } });
+      // Preloads don't run in about:blank windows, and file:// urls can't be loaded in iframes, so use a blank http page.
+      await w.loadURL(`data:text/html,<iframe src="http://localhost:${port}"></iframe>`);
+      w.webContents.mainFrame.frames[0].executeJavaScript('ipc.send(\'test\', 42)');
+      const [, arg] = await emittedOnce(w.webContents.ipc, 'test');
+      expect(arg).to.equal(42);
+    });
+  });
+
+  describe('WebFrameMain.ipc', () => {
+    afterEach(closeAllWindows);
+    it('responds to ipc messages in the main frame', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
+      const [, arg] = await emittedOnce(w.webContents.mainFrame.ipc, 'test');
+      expect(arg).to.equal(42);
+    });
+
+    it('responds to sync ipc messages in the main frame', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.mainFrame.ipc.on('test', (event, arg) => {
+        event.returnValue = arg * 2;
+      });
+      const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.sendSync(\'test\', 42)');
+      expect(result).to.equal(42 * 2);
+    });
+
+    it('receives postMessage messages sent from the WebContents, w/ MessagePorts', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.postMessage(\'test\', null, [(new MessageChannel).port1])');
+      const [event] = await emittedOnce(w.webContents.mainFrame.ipc, 'test');
+      expect(event.ports.length).to.equal(1);
+    });
+
+    it('handles invoke messages sent from the WebContents', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.mainFrame.ipc.handle('test', (_event, arg) => arg * 2);
+      const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
+      expect(result).to.equal(42 * 2);
+    });
+
+    it('cascades to WebContents and ipcMain', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      let gotFromIpcMain = false;
+      let gotFromWebContents = false;
+      const ipcMainReceived = new Promise<void>(resolve => ipcMain.on('test', () => { gotFromIpcMain = true; resolve(); }));
+      const ipcWebContentsReceived = new Promise<boolean>(resolve => w.webContents.ipc.on('test', () => { gotFromWebContents = true; resolve(gotFromIpcMain); }));
+      const ipcReceived = new Promise<boolean>(resolve => w.webContents.mainFrame.ipc.on('test', () => { resolve(gotFromWebContents); }));
+      defer(() => ipcMain.removeAllListeners('test'));
+      w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
+
+      // assert that they are delivered in the correct order
+      expect(await ipcReceived).to.be.false();
+      expect(await ipcWebContentsReceived).to.be.false();
+      await ipcMainReceived;
+    });
+
+    it('overrides ipcMain handlers', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.mainFrame.ipc.handle('test', (_event, arg) => arg * 2);
+      ipcMain.handle('test', () => { throw new Error('should not be called'); });
+      defer(() => ipcMain.removeHandler('test'));
+      const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
+      expect(result).to.equal(42 * 2);
+    });
+
+    it('overrides WebContents handlers', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.ipc.handle('test', () => { throw new Error('should not be called'); });
+      w.webContents.mainFrame.ipc.handle('test', (_event, arg) => arg * 2);
+      ipcMain.handle('test', () => { throw new Error('should not be called'); });
+      defer(() => ipcMain.removeHandler('test'));
+      const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
+      expect(result).to.equal(42 * 2);
+    });
+
+    it('falls back to WebContents handlers', async () => {
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
+      w.loadURL('about:blank');
+      w.webContents.ipc.handle('test', (_event, arg) => { return arg * 2; });
+      const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
+      expect(result).to.equal(42 * 2);
+    });
+
+    it('receives ipcs from child frames', async () => {
+      const server = http.createServer((req, res) => {
+        res.setHeader('content-type', 'text/html');
+        res.end('');
+      });
+      await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
+      const port = (server.address() as AddressInfo).port;
+      defer(() => {
+        server.close();
+      });
+      const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegrationInSubFrames: true, preload: path.resolve(fixturesPath, 'preload-expose-ipc.js') } });
+      // Preloads don't run in about:blank windows, and file:// urls can't be loaded in iframes, so use a blank http page.
+      await w.loadURL(`data:text/html,<iframe src="http://localhost:${port}"></iframe>`);
+      w.webContents.mainFrame.frames[0].executeJavaScript('ipc.send(\'test\', 42)');
+      w.webContents.mainFrame.ipc.on('test', () => { throw new Error('should not be called'); });
+      const [, arg] = await emittedOnce(w.webContents.mainFrame.frames[0].ipc, 'test');
+      expect(arg).to.equal(42);
+    });
+  });
 });

+ 14 - 0
spec-main/fixtures/preload-expose-ipc.js

@@ -0,0 +1,14 @@
+const { contextBridge, ipcRenderer } = require('electron');
+
+// NOTE: Never do this in an actual app! Very insecure!
+contextBridge.exposeInMainWorld('ipc', {
+  send (...args) {
+    return ipcRenderer.send(...args);
+  },
+  sendSync (...args) {
+    return ipcRenderer.sendSync(...args);
+  },
+  invoke (...args) {
+    return ipcRenderer.invoke(...args);
+  }
+});

+ 1 - 0
typings/internal-ambient.d.ts

@@ -237,6 +237,7 @@ declare namespace NodeJS {
     _linkedBinding(name: 'electron_browser_web_frame_main'): {
       WebFrameMain: typeof Electron.WebFrameMain;
       fromId(processId: number, routingId: number): Electron.WebFrameMain;
+      fromIdOrNull(processId: number, routingId: number): Electron.WebFrameMain;
     }
     _linkedBinding(name: 'electron_renderer_crash_reporter'): Electron.CrashReporter;
     _linkedBinding(name: 'electron_renderer_ipc'): { ipc: IpcRendererBinding };