[go: nahoru, domu]

Extensions: add Recorder extensions API

This CL adds the extension API for Recorder panel.

Bug: 1325751
Change-Id: Ibe4f13a2fd868530c72eab7562efe9ab33bbddd7
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3657448
Reviewed-by: Andrey Kosyakov <caseq@chromium.org>
Commit-Queue: Alex Rudenko <alexrudenko@chromium.org>
Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni
index 1ebb358..4cb9d72 100644
--- a/config/gni/devtools_grd_files.gni
+++ b/config/gni/devtools_grd_files.gni
@@ -714,11 +714,14 @@
   "front_end/models/emulation/DeviceModeModel.js",
   "front_end/models/emulation/EmulatedDevices.js",
   "front_end/models/extensions/ExtensionAPI.js",
+  "front_end/models/extensions/ExtensionEndpoint.js",
   "front_end/models/extensions/ExtensionPanel.js",
   "front_end/models/extensions/ExtensionServer.js",
   "front_end/models/extensions/ExtensionTraceProvider.js",
   "front_end/models/extensions/ExtensionView.js",
   "front_end/models/extensions/LanguageExtensionEndpoint.js",
+  "front_end/models/extensions/RecorderExtensionEndpoint.js",
+  "front_end/models/extensions/RecorderPluginManager.js",
   "front_end/models/formatter/FormatterWorkerPool.js",
   "front_end/models/formatter/ScriptFormatter.js",
   "front_end/models/formatter/SourceFormatter.js",
diff --git a/extension-api/ExtensionAPI.d.ts b/extension-api/ExtensionAPI.d.ts
index 1c4b50b..ba9d736 100644
--- a/extension-api/ExtensionAPI.d.ts
+++ b/extension-api/ExtensionAPI.d.ts
@@ -94,6 +94,7 @@
       panels: Panels;
       inspectedWindow: InspectedWindow;
       languageServices: LanguageExtensions;
+      recorder: RecorderExtensions;
     }
 
     export interface ExperimentalDevToolsAPI {
@@ -170,6 +171,10 @@
       payload: unknown;
     }
 
+    export interface RecorderExtensionPlugin {
+      stringify(obj: Record<string, any>): Promise<string>;
+    }
+
     export interface LanguageExtensionPlugin {
       /**
        * A new raw module has been loaded. If the raw wasm module references an external debug info module, its URL will be
@@ -272,6 +277,11 @@
       unregisterLanguageExtensionPlugin(plugin: LanguageExtensionPlugin): Promise<void>;
     }
 
+    export interface RecorderExtensions {
+      registerRecorderExtensionPlugin(plugin: RecorderExtensionPlugin, pluginName: string): Promise<void>;
+      unregisterRecorderExtensionPlugin(plugin: RecorderExtensionPlugin): Promise<void>;
+    }
+
     export interface Chrome {
       devtools: DevToolsAPI;
       experimental: {devtools: ExperimentalDevToolsAPI};
diff --git a/front_end/models/extensions/BUILD.gn b/front_end/models/extensions/BUILD.gn
index 4263f23..0ef4620 100644
--- a/front_end/models/extensions/BUILD.gn
+++ b/front_end/models/extensions/BUILD.gn
@@ -9,11 +9,14 @@
 devtools_module("extensions") {
   sources = [
     "ExtensionAPI.ts",
+    "ExtensionEndpoint.ts",
     "ExtensionPanel.ts",
     "ExtensionServer.ts",
     "ExtensionTraceProvider.ts",
     "ExtensionView.ts",
     "LanguageExtensionEndpoint.ts",
+    "RecorderExtensionEndpoint.ts",
+    "RecorderPluginManager.ts",
   ]
 
   deps = [
@@ -47,6 +50,7 @@
     "../../panels/elements/*",
     "../../panels/sources/*",
     "../../panels/timeline/*",
+    "//test/unittests/front_end/models/extensions:extensions",
   ]
 
   visibility += devtools_models_visibility
diff --git a/front_end/models/extensions/ExtensionAPI.ts b/front_end/models/extensions/ExtensionAPI.ts
index 3452865..7f76fcc 100644
--- a/front_end/models/extensions/ExtensionAPI.ts
+++ b/front_end/models/extensions/ExtensionAPI.ts
@@ -86,6 +86,7 @@
     Unsubscribe = 'unsubscribe',
     UpdateButton = 'updateButton',
     RegisterLanguageExtensionPlugin = 'registerLanguageExtensionPlugin',
+    RegisterRecorderExtensionPlugin = 'registerRecorderExtensionPlugin',
   }
 
   export const enum LanguageExtensionPluginCommands {
@@ -108,6 +109,14 @@
     UnregisteredLanguageExtensionPlugin = 'unregisteredLanguageExtensionPlugin',
   }
 
+  export const enum RecorderExtensionPluginCommands {
+    Stringify = 'stringify',
+  }
+
+  export const enum RecorderExtensionPluginEvents {
+    UnregisteredRecorderExtensionPlugin = 'unregisteredRecorderExtensionPlugin',
+  }
+
   export interface EvaluateOptions {
     frameURL?: string;
     useContentScriptContext?: boolean;
@@ -120,6 +129,11 @@
     port: MessagePort,
     supportedScriptTypes: PublicAPI.Chrome.DevTools.SupportedScriptTypes,
   };
+  type RegisterRecorderExtensionPluginRequest = {
+    command: Commands.RegisterRecorderExtensionPlugin,
+    pluginName: string,
+    port: MessagePort,
+  };
   type SubscribeRequest = {command: Commands.Subscribe, type: string};
   type UnsubscribeRequest = {command: Commands.Unsubscribe, type: string};
   type AddRequestHeadersRequest = {
@@ -182,13 +196,13 @@
   type GetHARRequest = {command: Commands.GetHAR};
   type GetPageResourcesRequest = {command: Commands.GetPageResources};
 
-  export type ServerRequests = RegisterLanguageExtensionPluginRequest|SubscribeRequest|UnsubscribeRequest|
-      AddRequestHeadersRequest|ApplyStyleSheetRequest|CreatePanelRequest|ShowPanelRequest|CreateToolbarButtonRequest|
-      UpdateButtonRequest|CompleteTraceSessionRequest|CreateSidebarPaneRequest|SetSidebarHeightRequest|
-      SetSidebarContentRequest|SetSidebarPageRequest|OpenResourceRequest|SetOpenResourceHandlerRequest|
-      SetThemeChangeHandlerRequest|ReloadRequest|EvaluateOnInspectedPageRequest|GetRequestContentRequest|
-      GetResourceContentRequest|SetResourceContentRequest|AddTraceProviderRequest|ForwardKeyboardEventRequest|
-      GetHARRequest|GetPageResourcesRequest;
+  export type ServerRequests = RegisterRecorderExtensionPluginRequest|RegisterLanguageExtensionPluginRequest|
+      SubscribeRequest|UnsubscribeRequest|AddRequestHeadersRequest|ApplyStyleSheetRequest|CreatePanelRequest|
+      ShowPanelRequest|CreateToolbarButtonRequest|UpdateButtonRequest|CompleteTraceSessionRequest|
+      CreateSidebarPaneRequest|SetSidebarHeightRequest|SetSidebarContentRequest|SetSidebarPageRequest|
+      OpenResourceRequest|SetOpenResourceHandlerRequest|SetThemeChangeHandlerRequest|ReloadRequest|
+      EvaluateOnInspectedPageRequest|GetRequestContentRequest|GetResourceContentRequest|SetResourceContentRequest|
+      AddTraceProviderRequest|ForwardKeyboardEventRequest|GetHARRequest|GetPageResourcesRequest;
   export type ExtensionServerRequestMessage = PrivateAPI.ServerRequests&{requestId?: number};
 
   type AddRawModuleRequest = {
@@ -256,6 +270,13 @@
       RawLocationToSourceLocationRequest|GetScopeInfoRequest|ListVariablesInScopeRequest|RemoveRawModuleRequest|
       GetTypeInfoRequest|GetFormatterRequest|GetInspectableAddressRequest|GetFunctionInfoRequest|
       GetInlinedFunctionRangesRequest|GetInlinedCalleesRangesRequest|GetMappedLinesRequest;
+
+  type StringifyRequest = {
+    method: RecorderExtensionPluginCommands.Stringify,
+    parameters: {recording: Record<string, unknown>},
+  };
+
+  export type RecorderExtensionRequests = StringifyRequest;
 }
 
 declare global {
@@ -264,7 +285,7 @@
         (extensionInfo: ExtensionDescriptor, inspectedTabId: string, themeName: string, keysToForward: number[],
          testHook:
              (extensionServer: APIImpl.ExtensionServerClient, extensionAPI: APIImpl.InspectorExtensionAPI) => unknown,
-         injectedScriptId: number) => void;
+         injectedScriptId: number, targetWindow?: Window) => void;
     buildExtensionAPIInjectedScript(
         extensionInfo: ExtensionDescriptor, inspectedTabId: string, themeName: string, keysToForward: number[],
         testHook: undefined|((extensionServer: unknown, extensionAPI: unknown) => unknown)): string;
@@ -283,6 +304,7 @@
 namespace APIImpl {
   export interface InspectorExtensionAPI {
     languageServices: PublicAPI.Chrome.DevTools.LanguageExtensions;
+    recorder: PublicAPI.Chrome.DevTools.RecorderExtensions;
     timeline: Timeline;
     network: PublicAPI.Chrome.DevTools.Network;
     panels: PublicAPI.Chrome.DevTools.Panels;
@@ -357,6 +379,10 @@
     _plugins: Map<PublicAPI.Chrome.DevTools.LanguageExtensionPlugin, MessagePort>;
   }
 
+  export interface RecorderExtensions extends PublicAPI.Chrome.DevTools.RecorderExtensions {
+    _plugins: Map<PublicAPI.Chrome.DevTools.RecorderExtensionPlugin, MessagePort>;
+  }
+
   export interface ExtensionPanel extends ExtensionView, PublicAPI.Chrome.DevTools.ExtensionPanel {
     show(): void;
   }
@@ -392,7 +418,7 @@
 self.injectedExtensionAPI = function(
     extensionInfo: ExtensionDescriptor, inspectedTabId: string, themeName: string, keysToForward: number[],
     testHook: (extensionServer: APIImpl.ExtensionServerClient, extensionAPI: APIImpl.InspectorExtensionAPI) => unknown,
-    injectedScriptId: number): void {
+    injectedScriptId: number, targetWindowForTest?: Window): void {
   const keysToForwardSet = new Set<number>(keysToForward);
   const chrome = window.chrome || {};
 
@@ -473,6 +499,7 @@
     this.network = new (Constructor(Network))();
     this.timeline = new (Constructor(Timeline))();
     this.languageServices = new (Constructor(LanguageServicesAPI))();
+    this.recorder = new (Constructor(RecorderServicesAPI))();
     defineDeprecatedProperty(this, 'webInspector', 'resources', 'network');
   }
 
@@ -671,6 +698,60 @@
     __proto__: ExtensionViewImpl.prototype,
   };
 
+  function RecorderServicesAPIImpl(this: APIImpl.RecorderExtensions): void {
+    this._plugins = new Map();
+  }
+
+  (RecorderServicesAPIImpl.prototype as
+   Pick<APIImpl.RecorderExtensions, 'registerRecorderExtensionPlugin'|'unregisterRecorderExtensionPlugin'>) = {
+    registerRecorderExtensionPlugin: async function(
+        this: APIImpl.RecorderExtensions, plugin: PublicAPI.Chrome.DevTools.RecorderExtensionPlugin,
+        pluginName: string): Promise<void> {
+      if (this._plugins.has(plugin)) {
+        throw new Error(`Tried to register plugin '${pluginName}' twice`);
+      }
+      const channel = new MessageChannel();
+      const port = channel.port1;
+      this._plugins.set(plugin, port);
+      port. MessageEvent<{requestId: number}&PrivateAPI.RecorderExtensionRequests>): void => {
+        const {requestId} = data;
+        dispatchMethodCall(data)
+            .then(result => port.postMessage({requestId, result}))
+            .catch(error => port.postMessage({requestId, error: {message: error.message}}));
+      };
+
+      function dispatchMethodCall(request: PrivateAPI.RecorderExtensionRequests): Promise<unknown> {
+        switch (request.method) {
+          case PrivateAPI.RecorderExtensionPluginCommands.Stringify:
+            return plugin.stringify(request.parameters.recording);
+          default:
+            throw new Error(`'${request.method}' is not recognized`);
+        }
+      }
+
+      await new Promise<void>(resolve => {
+        extensionServer.sendRequest(
+            {
+              command: PrivateAPI.Commands.RegisterRecorderExtensionPlugin,
+              pluginName,
+              port: channel.port2,
+            },
+            () => resolve(), [channel.port2]);
+      });
+    },
+
+    unregisterRecorderExtensionPlugin: async function(
+        this: APIImpl.RecorderExtensions, plugin: PublicAPI.Chrome.DevTools.RecorderExtensionPlugin): Promise<void> {
+      const port = this._plugins.get(plugin);
+      if (!port) {
+        throw new Error('Tried to unregister a plugin that was not previously registered');
+      }
+      this._plugins.delete(plugin);
+      port.postMessage({event: PrivateAPI.RecorderExtensionPluginEvents.UnregisteredRecorderExtensionPlugin});
+      port.close();
+    },
+  };
+
   function LanguageServicesAPIImpl(this: APIImpl.LanguageExtensions): void {
     this._plugins = new Map();
   }
@@ -787,6 +868,7 @@
   }
 
   const LanguageServicesAPI = declareInterfaceClass(LanguageServicesAPIImpl);
+  const RecorderServicesAPI = declareInterfaceClass(RecorderServicesAPIImpl);
   const Button = declareInterfaceClass(ButtonImpl);
   const EventSink = declareInterfaceClass(EventSinkImpl);
   const ExtensionPanel = declareInterfaceClass(ExtensionPanelImpl);
@@ -1128,7 +1210,7 @@
 
   document.addEventListener('keydown', forwardKeyboardEvent, false);
 
-  function ExtensionServerClient(this: APIImpl.ExtensionServerClient): void {
+  function ExtensionServerClient(this: APIImpl.ExtensionServerClient, targetWindow: Window): void {
     this._callbacks = {};
     this._handlers = {};
     this._lastRequestId = 0;
@@ -1141,7 +1223,7 @@
     this._port.addEventListener('message', this._onMessage.bind(this), false);
     this._port.start();
 
-    window.parent.postMessage('registerExtension', '*', [channel.port2]);
+    targetWindow.postMessage('registerExtension', '*', [channel.port2]);
   }
 
   (ExtensionServerClient.prototype as Pick<
@@ -1225,7 +1307,7 @@
     }
   }
 
-  const extensionServer = new (Constructor(ExtensionServerClient))();
+  const extensionServer = new (Constructor(ExtensionServerClient))(targetWindowForTest || window.parent);
 
   const coreAPI = new (Constructor(InspectorExtensionAPI))();
 
@@ -1241,6 +1323,7 @@
   chrome.devtools!.panels = coreAPI.panels;
   chrome.devtools!.panels.themeName = themeName;
   chrome.devtools!.languageServices = coreAPI.languageServices;
+  chrome.devtools!.recorder = coreAPI.recorder;
 
   // default to expose experimental APIs for now.
   if (extensionInfo.exposeExperimentalAPIs !== false) {
diff --git a/front_end/models/extensions/ExtensionEndpoint.ts b/front_end/models/extensions/ExtensionEndpoint.ts
new file mode 100644
index 0000000..43fbb23
--- /dev/null
+++ b/front_end/models/extensions/ExtensionEndpoint.ts
@@ -0,0 +1,69 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+type Response = {
+  requestId: number,
+  result: unknown,
+  error: Error|null,
+};
+
+type Event = {
+  event: string,
+};
+
+type Message = MessageEvent<Response|Event>;
+
+export class ExtensionEndpoint {
+  private readonly port: MessagePort;
+  private nextRequestId: number = 0;
+  private pendingRequests: Map<number, {
+    resolve: (arg: unknown) => void,
+    reject: (error: Error) => void,
+  }>;
+
+  constructor(port: MessagePort) {
+    this.port = port;
+    this.port.>
+    this.pendingRequests = new Map();
+  }
+
+  sendRequest<ReturnType>(method: string, parameters: unknown): Promise<ReturnType> {
+    return new Promise((resolve, reject) => {
+      const requestId = this.nextRequestId++;
+      this.pendingRequests.set(requestId, {resolve: resolve as (arg: unknown) => void, reject});
+      this.port.postMessage({requestId, method, parameters});
+    });
+  }
+
+  protected disconnect(): void {
+    for (const {reject} of this.pendingRequests.values()) {
+      reject(new Error('Extension endpoint disconnected'));
+    }
+    this.pendingRequests.clear();
+    this.port.close();
+  }
+
+  private onResponse({data}: Message): void {
+    if ('event' in data) {
+      this.handleEvent(data);
+      return;
+    }
+    const {requestId, result, error} = data;
+    const pendingRequest = this.pendingRequests.get(requestId);
+    if (!pendingRequest) {
+      console.error(`No pending request ${requestId}`);
+      return;
+    }
+    this.pendingRequests.delete(requestId);
+    if (error) {
+      pendingRequest.reject(new Error(error.message));
+    } else {
+      pendingRequest.resolve(result);
+    }
+  }
+
+  protected handleEvent(_event: Event): void {
+    throw new Error('handleEvent is not implemented');
+  }
+}
diff --git a/front_end/models/extensions/ExtensionServer.ts b/front_end/models/extensions/ExtensionServer.ts
index 9895a1e..7c71e57 100644
--- a/front_end/models/extensions/ExtensionServer.ts
+++ b/front_end/models/extensions/ExtensionServer.ts
@@ -52,7 +52,9 @@
 import type {TracingSession} from './ExtensionTraceProvider.js';
 import {ExtensionTraceProvider} from './ExtensionTraceProvider.js';
 import {LanguageExtensionEndpoint} from './LanguageExtensionEndpoint.js';
+import {RecorderExtensionEndpoint} from './RecorderExtensionEndpoint.js';
 import {PrivateAPI} from './ExtensionAPI.js';
+import {RecorderPluginManager} from './RecorderPluginManager.js';
 
 const extensionOrigins: WeakMap<MessagePort, Platform.DevToolsPath.UrlString> = new WeakMap();
 
@@ -138,6 +140,8 @@
     this.registerHandler(PrivateAPI.Commands.UpdateButton, this.onUpdateButton.bind(this));
     this.registerHandler(
         PrivateAPI.Commands.RegisterLanguageExtensionPlugin, this.registerLanguageExtensionEndpoint.bind(this));
+    this.registerHandler(
+        PrivateAPI.Commands.RegisterRecorderExtensionPlugin, this.registerRecorderExtensionEndpoint.bind(this));
     window.addEventListener('message', this.onWindowMessage.bind(this), false);  // Only for main window.
 
     const existingTabId =
@@ -215,6 +219,16 @@
     return this.status.OK();
   }
 
+  private registerRecorderExtensionEndpoint(
+      message: PrivateAPI.ExtensionServerRequestMessage, _shared_port: MessagePort): Record {
+    if (message.command !== PrivateAPI.Commands.RegisterRecorderExtensionPlugin) {
+      return this.status.E_BADARG('command', `expected ${PrivateAPI.Commands.Subscribe}`);
+    }
+    const {pluginName, port} = message;
+    RecorderPluginManager.instance().addPlugin(new RecorderExtensionEndpoint(pluginName, port));
+    return this.status.OK();
+  }
+
   private inspectedURLChanged(event: Common.EventTarget.EventTargetEvent<SDK.Target.Target>): void {
     if (!this.canInspectURL(event.data.inspectedURL())) {
       this.disableExtensions();
@@ -856,6 +870,13 @@
     }
   }
 
+  addExtensionForTest(extensionInfo: Host.InspectorFrontendHostAPI.ExtensionDescriptor, origin: string): boolean
+      |undefined {
+    const name = extensionInfo.name || `Extension ${origin}`;
+    this.registeredExtensions.set(origin, {name});
+    return true;
+  }
+
   private addExtension(extensionInfo: Host.InspectorFrontendHostAPI.ExtensionDescriptor): boolean|undefined {
     const startPage = extensionInfo.startPage;
 
diff --git a/front_end/models/extensions/LanguageExtensionEndpoint.ts b/front_end/models/extensions/LanguageExtensionEndpoint.ts
index b13592a..f191458 100644
--- a/front_end/models/extensions/LanguageExtensionEndpoint.ts
+++ b/front_end/models/extensions/LanguageExtensionEndpoint.ts
@@ -5,9 +5,30 @@
 import type * as SDK from '../../core/sdk/sdk.js';
 import * as Bindings from '../bindings/bindings.js';
 import type {Chrome} from '../../../extension-api/ExtensionAPI.js'; // eslint-disable-line rulesdir/es_modules_import
+import {ExtensionEndpoint} from './ExtensionEndpoint.js';
 
 import {PrivateAPI} from './ExtensionAPI.js';
 
+class LanguageExtensionEndpointImpl extends ExtensionEndpoint {
+  private plugin: LanguageExtensionEndpoint;
+  constructor(plugin: LanguageExtensionEndpoint, port: MessagePort) {
+    super(port);
+    this.plugin = plugin;
+  }
+  protected handleEvent({event}: {event: string}): void {
+    switch (event) {
+      case PrivateAPI.LanguageExtensionPluginEvents.UnregisteredLanguageExtensionPlugin: {
+        this.disconnect();
+        const {pluginManager} = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance();
+        if (pluginManager) {
+          pluginManager.removePlugin(this.plugin);
+        }
+        break;
+      }
+    }
+  }
+}
+
 export class LanguageExtensionEndpoint extends Bindings.DebuggerLanguagePlugins.DebuggerLanguagePlugin {
   private readonly supportedScriptTypes: {
     language: string,
@@ -15,13 +36,7 @@
     // eslint-disable-next-line @typescript-eslint/naming-convention
     symbol_types: Array<string>,
   };
-  private readonly port: MessagePort;
-  private nextRequestId: number;
-  // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private pendingRequests: Map<any, any>;
+  private endpoint: LanguageExtensionEndpointImpl;
   constructor(
       name: string, supportedScriptTypes: {
         language: string,
@@ -32,63 +47,7 @@
       port: MessagePort) {
     super(name);
     this.supportedScriptTypes = supportedScriptTypes;
-    this.port = port;
-    this.port.>
-    this.nextRequestId = 0;
-    this.pendingRequests = new Map();
-  }
-
-  // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private sendRequest(method: string, parameters: any): Promise<any> {
-    return new Promise((resolve, reject) => {
-      const requestId = this.nextRequestId++;
-      this.pendingRequests.set(requestId, {resolve, reject});
-      this.port.postMessage({requestId, method, parameters});
-    });
-  }
-
-  private onResponse({data}: MessageEvent<{
-    requestId: number,
-    // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    result: any,
-    error: Error|null,
-  }|{
-    event: string,
-  }>): void {
-    if ('event' in data) {
-      const {event} = data;
-      switch (event) {
-        case PrivateAPI.LanguageExtensionPluginEvents.UnregisteredLanguageExtensionPlugin: {
-          for (const {reject} of this.pendingRequests.values()) {
-            reject(new Error('Language extension endpoint disconnected'));
-          }
-          this.pendingRequests.clear();
-          this.port.close();
-          const {pluginManager} = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance();
-          if (pluginManager) {
-            pluginManager.removePlugin(this);
-          }
-          break;
-        }
-      }
-      return;
-    }
-    const {requestId, result, error} = data;
-    if (!this.pendingRequests.has(requestId)) {
-      console.error(`No pending request ${requestId}`);
-      return;
-    }
-    const {resolve, reject} = this.pendingRequests.get(requestId);
-    this.pendingRequests.delete(requestId);
-    if (error) {
-      reject(new Error(error.message));
-    } else {
-      resolve(result);
-    }
+    this.endpoint = new LanguageExtensionEndpointImpl(this, port);
   }
 
   handleScript(script: SDK.Script.Script): boolean {
@@ -100,7 +59,7 @@
   /** Notify the plugin about a new script
      */
   addRawModule(rawModuleId: string, symbolsURL: string, rawModule: Chrome.DevTools.RawModule): Promise<string[]> {
-    return this.sendRequest(
+    return this.endpoint.sendRequest(
                PrivateAPI.LanguageExtensionPluginCommands.AddRawModule, {rawModuleId, symbolsURL, rawModule}) as
         Promise<string[]>;
   }
@@ -109,33 +68,36 @@
    * Notifies the plugin that a script is removed.
    */
   removeRawModule(rawModuleId: string): Promise<void> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.RemoveRawModule, {rawModuleId}) as Promise<void>;
+    return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.RemoveRawModule, {rawModuleId}) as
+        Promise<void>;
   }
 
   /** Find locations in raw modules from a location in a source file
      */
   sourceLocationToRawLocation(sourceLocation: Chrome.DevTools.SourceLocation):
       Promise<Chrome.DevTools.RawLocationRange[]> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.SourceLocationToRawLocation, {sourceLocation}) as
+    return this.endpoint.sendRequest(
+               PrivateAPI.LanguageExtensionPluginCommands.SourceLocationToRawLocation, {sourceLocation}) as
         Promise<Chrome.DevTools.RawLocationRange[]>;
   }
 
   /** Find locations in source files from a location in a raw module
      */
   rawLocationToSourceLocation(rawLocation: Chrome.DevTools.RawLocation): Promise<Chrome.DevTools.SourceLocation[]> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.RawLocationToSourceLocation, {rawLocation}) as
+    return this.endpoint.sendRequest(
+               PrivateAPI.LanguageExtensionPluginCommands.RawLocationToSourceLocation, {rawLocation}) as
         Promise<Chrome.DevTools.SourceLocation[]>;
   }
 
   getScopeInfo(type: string): Promise<Chrome.DevTools.ScopeInfo> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetScopeInfo, {type}) as
+    return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetScopeInfo, {type}) as
         Promise<Chrome.DevTools.ScopeInfo>;
   }
 
   /** List all variables in lexical scope at a given location in a raw module
      */
   listVariablesInScope(rawLocation: Chrome.DevTools.RawLocation): Promise<Chrome.DevTools.Variable[]> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.ListVariablesInScope, {rawLocation}) as
+    return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.ListVariablesInScope, {rawLocation}) as
         Promise<Chrome.DevTools.Variable[]>;
   }
 
@@ -144,7 +106,8 @@
   getFunctionInfo(rawLocation: Chrome.DevTools.RawLocation): Promise<{
     frames: Array<Chrome.DevTools.FunctionInfo>,
   }> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetFunctionInfo, {rawLocation}) as Promise<{
+    return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetFunctionInfo, {rawLocation}) as
+        Promise<{
              frames: Array<Chrome.DevTools.FunctionInfo>,
            }>;
   }
@@ -153,7 +116,8 @@
      *  that rawLocation is in.
      */
   getInlinedFunctionRanges(rawLocation: Chrome.DevTools.RawLocation): Promise<Chrome.DevTools.RawLocationRange[]> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetInlinedFunctionRanges, {rawLocation}) as
+    return this.endpoint.sendRequest(
+               PrivateAPI.LanguageExtensionPluginCommands.GetInlinedFunctionRanges, {rawLocation}) as
         Promise<Chrome.DevTools.RawLocationRange[]>;
   }
 
@@ -161,7 +125,8 @@
      *  called by the function or inline frame that rawLocation is in.
      */
   getInlinedCalleesRanges(rawLocation: Chrome.DevTools.RawLocation): Promise<Chrome.DevTools.RawLocationRange[]> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetInlinedCalleesRanges, {rawLocation}) as
+    return this.endpoint.sendRequest(
+               PrivateAPI.LanguageExtensionPluginCommands.GetInlinedCalleesRanges, {rawLocation}) as
         Promise<Chrome.DevTools.RawLocationRange[]>;
   }
 
@@ -169,7 +134,8 @@
     typeInfos: Array<Chrome.DevTools.TypeInfo>,
     base: Chrome.DevTools.EvalBase,
   }|null> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetTypeInfo, {expression, context}) as Promise<{
+    return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetTypeInfo, {expression, context}) as
+        Promise<{
              typeInfos: Array<Chrome.DevTools.TypeInfo>,
              base: Chrome.DevTools.EvalBase,
            }|null>;
@@ -183,8 +149,8 @@
       context: Chrome.DevTools.RawLocation): Promise<{
     js: string,
   }> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetFormatter, {expressionOrField, context}) as
-        Promise<{
+    return this.endpoint.sendRequest(
+               PrivateAPI.LanguageExtensionPluginCommands.GetFormatter, {expressionOrField, context}) as Promise<{
              js: string,
            }>;
   }
@@ -195,13 +161,15 @@
   }): Promise<{
     js: string,
   }> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetInspectableAddress, {field}) as Promise<{
+    return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetInspectableAddress, {field}) as
+        Promise<{
              js: string,
            }>;
   }
 
   async getMappedLines(rawModuleId: string, sourceFileURL: string): Promise<number[]|undefined> {
-    return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetMappedLines, {rawModuleId, sourceFileURL});
+    return this.endpoint.sendRequest(
+        PrivateAPI.LanguageExtensionPluginCommands.GetMappedLines, {rawModuleId, sourceFileURL});
   }
 
   dispose(): void {
diff --git a/front_end/models/extensions/RecorderExtensionEndpoint.ts b/front_end/models/extensions/RecorderExtensionEndpoint.ts
new file mode 100644
index 0000000..945b869
--- /dev/null
+++ b/front_end/models/extensions/RecorderExtensionEndpoint.ts
@@ -0,0 +1,43 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {PrivateAPI} from './ExtensionAPI.js';
+import {ExtensionEndpoint} from './ExtensionEndpoint.js';
+import {RecorderPluginManager} from './RecorderPluginManager.js';
+
+export class RecorderExtensionEndpoint extends ExtensionEndpoint {
+  private readonly name: string;
+
+  constructor(name: string, port: MessagePort) {
+    super(port);
+    this.name = name;
+  }
+
+  getName(): string {
+    return this.name;
+  }
+
+  protected handleEvent({event}: {event: string}): void {
+    switch (event) {
+      case PrivateAPI.RecorderExtensionPluginEvents.UnregisteredRecorderExtensionPlugin: {
+        this.disconnect();
+        RecorderPluginManager.instance().removePlugin(this);
+        break;
+      }
+      default:
+        throw new Error(`Unrecognized Recorder extension endpoint event: ${event}`);
+    }
+  }
+
+  /**
+   * In practice, `recording` is a UserFlow[1], but we avoid defining this type on the
+   * API in order to prevent dependencies between Chrome and puppeteer. Extensions
+   * are responsible for working out potential compatibility issues.
+   *
+   * [1]: https://github.com/puppeteer/replay/blob/main/src/Schema.ts#L245
+   */
+  stringify(recording: Object): Promise<string> {
+    return this.sendRequest(PrivateAPI.RecorderExtensionPluginCommands.Stringify, {recording});
+  }
+}
diff --git a/front_end/models/extensions/RecorderPluginManager.ts b/front_end/models/extensions/RecorderPluginManager.ts
new file mode 100644
index 0000000..93e53ce
--- /dev/null
+++ b/front_end/models/extensions/RecorderPluginManager.ts
@@ -0,0 +1,30 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type {RecorderExtensionEndpoint} from './RecorderExtensionEndpoint.js';
+
+let instance: RecorderPluginManager|null = null;
+
+export class RecorderPluginManager {
+  #plugins: Set<RecorderExtensionEndpoint> = new Set();
+
+  static instance(): RecorderPluginManager {
+    if (!instance) {
+      instance = new RecorderPluginManager();
+    }
+    return instance;
+  }
+
+  addPlugin(plugin: RecorderExtensionEndpoint): void {
+    this.#plugins.add(plugin);
+  }
+
+  removePlugin(plugin: RecorderExtensionEndpoint): void {
+    this.#plugins.delete(plugin);
+  }
+
+  plugins(): RecorderExtensionEndpoint[] {
+    return Array.from(this.#plugins.values());
+  }
+}
diff --git a/front_end/models/extensions/extensions.ts b/front_end/models/extensions/extensions.ts
index 37d0289..a250ad2 100644
--- a/front_end/models/extensions/extensions.ts
+++ b/front_end/models/extensions/extensions.ts
@@ -7,6 +7,7 @@
 import * as ExtensionServer from './ExtensionServer.js';
 import * as ExtensionTraceProvider from './ExtensionTraceProvider.js';
 import * as ExtensionView from './ExtensionView.js';
+import * as RecorderPluginManager from './RecorderPluginManager.js';
 
 export {
   ExtensionAPI,
@@ -14,4 +15,5 @@
   ExtensionServer,
   ExtensionTraceProvider,
   ExtensionView,
+  RecorderPluginManager,
 };
diff --git a/test/unittests/front_end/BUILD.gn b/test/unittests/front_end/BUILD.gn
index aa48d7c..7bc8148 100644
--- a/test/unittests/front_end/BUILD.gn
+++ b/test/unittests/front_end/BUILD.gn
@@ -18,6 +18,7 @@
     "entrypoints/missing_entrypoints",
     "models/bindings",
     "models/emulation",
+    "models/extensions",
     "models/formatter",
     "models/har",
     "models/issues_manager",
diff --git a/test/unittests/front_end/models/extensions/BUILD.gn b/test/unittests/front_end/models/extensions/BUILD.gn
new file mode 100644
index 0000000..b9fdbc4
--- /dev/null
+++ b/test/unittests/front_end/models/extensions/BUILD.gn
@@ -0,0 +1,12 @@
+# Copyright 2022 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("extensions") {
+  testonly = true
+  sources = [ "ExtensionServer_test.ts" ]
+
+  deps = [ "../../../../../front_end/models/extensions:bundle" ]
+}
diff --git a/test/unittests/front_end/models/extensions/ExtensionServer_test.ts b/test/unittests/front_end/models/extensions/ExtensionServer_test.ts
new file mode 100644
index 0000000..823bd72
--- /dev/null
+++ b/test/unittests/front_end/models/extensions/ExtensionServer_test.ts
@@ -0,0 +1,50 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Extensions from '../../../../../front_end/models/extensions/extensions.js';
+import type {Chrome} from '../../../../../extension-api/ExtensionAPI.js';
+
+const {assert} = chai;
+
+describe('Extensions', () => {
+  function cleanup() {
+    delete (window as {chrome?: Chrome.DevTools.Chrome}).chrome;
+  }
+
+  beforeEach(cleanup);
+  afterEach(cleanup);
+
+  it('can register a recorder extension', async () => {
+    const server = Extensions.ExtensionServer.ExtensionServer.instance({forceNew: true});
+    const extensionDescriptor = {
+      startPage: 'blank.html',
+      name: 'TestExtension',
+      exposeExperimentalAPIs: true,
+    };
+    server.addExtensionForTest(extensionDescriptor, window.location.origin);
+    const chrome: Partial<Chrome.DevTools.Chrome> = {};
+    (window as {chrome?: Partial<Chrome.DevTools.Chrome>}).chrome = chrome;
+
+    self.injectedExtensionAPI(extensionDescriptor, 'main', 'dark', [], () => {}, 1, window);
+
+    class RecorderPlugin {
+      async stringify(recording: object) {
+        return JSON.stringify(recording);
+      }
+    }
+    await chrome.devtools?.recorder.registerRecorderExtensionPlugin(new RecorderPlugin(), 'Test');
+
+    const manager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
+    assert.strictEqual(manager.plugins().length, 1);
+    const plugin = manager.plugins()[0];
+
+    const result = await plugin.stringify({
+      name: 'test',
+      steps: [],
+    });
+
+    assert.strictEqual(manager.plugins().length, 1);
+    assert.deepStrictEqual(result, '{"name":"test","steps":[]}');
+  });
+});