chrome-extension-tools icon indicating copy to clipboard operation
chrome-extension-tools copied to clipboard

Routing inside the chrome-extension

Open steinhardt21 opened this issue 10 months ago • 6 comments

Describe the problem

Which is the system that you advise when using this framework for the routing part? What do you think about this library https://github.com/Scout-NU/route-lite?

Describe the proposed solution

A routing system inside a complex Chrome extension that has to manage various pages (Login, Home, etc.) inside a Chrome extension that show up to the user such as an iframe.

Alternatives considered

https://github.com/Scout-NU/route-lite

Importance

nice to have

steinhardt21 avatar Aug 28 '23 12:08 steinhardt21

I am successfully using Vuejs router inside extensions for Content Pages, Popup and Options pages, details here: https://github.com/mubaidr/vite-vue3-chrome-extension-v3

mubaidr avatar Sep 02 '23 09:09 mubaidr

This is very ambiguous question, likely outside the scope of this project, and probably should be posted on Stack Overflow instead.

If you need a router in a tab extension page, then use any of the framework router libraries like React Router or svelte-spa-router. I have successfully created a router injected into a content page which checks the page url like github.com/home or github.com/login, matching '/home' and '/login', before calling a function which will take over the page with inject code.

import handleGithubHome from './routes/home'
import handleGithubLogin from './routes/login'
const params = {info: 'about the current context'}
const map = { '/home': handleGithubHome, '/login': handleGithubLogin}
const routerFunc = <some logic to test for '/home' or '/login'
routerFunc(params)

You can easily check the url when the content script is loaded and call a different function to handle that page.

adam-s avatar Oct 01 '23 00:10 adam-s

@adam-s how do you check page url from content script? i thought that might be more feasible from background script

devhandler avatar Nov 27 '23 01:11 devhandler

@devhandler Here is an example of how I solved it. Watch for URL changes using the background service worker. What is important is using the background script to listen for changes to the url and inform the content script. Then the content script uses window.location.href.


// Define a function to send URL changes to the 'robinhood.com' tab.
const sendUrlChangeToRobinhood = (
  details: chrome.webNavigation.WebNavigationTransitionCallbackDetails
) => {
  const url = new URL(details.url);

  if (url.hostname === 'robinhood.com') {
    chrome.tabs.sendMessage(details.tabId, {
      type: 'URL_CHANGE',
      message: details.url,
    });
  }
};

// Add the above function as a listener to chrome's webNavigation.onHistoryStateUpdated event.
chrome.webNavigation.onHistoryStateUpdated.addListener(
  sendUrlChangeToRobinhood
);

This implements a custom router which doesn't matter so much as the jist of what it does.

const connection = new ContentScriptConnection('ROBINHOOD');
const api = new API(getHeaders);

const home = new Home(connection, api);
const options = new Options(connection, api);
const optionsChains = new OptionsChains(connection, api);
const stocks = new Stocks(connection, api);

const router = new RobinhoodRouter();
router.addRoute('/', home);
router.addRoute('options/:id', options);
router.addRoute('stocks/:symbol', stocks);
router.addRoute('options/chains/:symbol', optionsChains);
router.start();

The router has a listener that calls the parents route method.

import { Router } from '../lib/Router';

/**
 * Class representing a custom router specifically for the Robinhood application.
 * Extends from the general Router class.
 */
export class RobinhoodRouter extends Router {
  // The previousUrl property keeps track of the last URL the router has processed.
  private previousUrl: string;

  /**
   * Creates a new instance of the RobinhoodRouter.
   */
  constructor() {
    super();
    this.previousUrl = '';
  }

  /**
   * Starts the router.
   * It sets up a listener for changes in the history state and initializes the routing process.
   */
  start(): void {
    // Add a listener for messages from the background script.
    // The background script will send a message when the URL changes.
    chrome.runtime.onMessage.addListener(
      (event: { type: string; message: string }) => {
        // If the received message indicates a URL change...
        if (event.type === 'URL_CHANGE') {
          // Check if the new URL is different from the previous URL.
          // This check is necessary because the onHistoryStateUpdated event can be triggered multiple times for a single navigation event.
          if (this.previousUrl !== event.message) {
            // Update the previousUrl property and route to the new URL.
            this.previousUrl = window.location.href;
            this.route(event.message);
          }
        }
      }
    );

    // Kick off the routing process by routing to the current URL.
    // Also update the previousUrl property.
    this.route(window.location.href);
    this.previousUrl = window.location.href;
  }
}

This will call the handleRoute method that is provided as the map

export interface IRoute {
  handleRoute: (params?: Record<string, string>) => Promise<void>;
  destroy?: () => void;
}

export class Router {
  routes: Map<string, IRoute>;
  currentRoute?: IRoute;

  constructor() {
    this.routes = new Map();
  }

  addRoute(route: string, handler: IRoute): void {
    this.routes.set(route, handler);
  }

  async route(url: string): Promise<void> {
    // If there's a current route and it has a cleanup function, execute it
    if (this.currentRoute && this.currentRoute.destroy) {
      this.currentRoute.destroy();
      this.currentRoute = undefined;
    }

    const urlObject = new URL(url);
    for (const [route, handler] of this.routes) {
      const params = this.matchRoute(urlObject.pathname, route);
      if (params) {
        // Execute the handler and get the cleanup function, if any
        await handler.handleRoute(params);
        // Set the current route to the one we just routed to
        this.currentRoute = handler;
        return;
      }
    }
  }

  matchRoute(path: string, route: string): Record<string, string> | null {
    const pathSegments = path.split('/').filter(Boolean);
    const routeSegments = route.split('/').filter(Boolean);

    if (pathSegments.length !== routeSegments.length) {
      return null;
    }

    const params: Record<string, string> = {};

    for (let i = 0; i < routeSegments.length; i++) {
      if (routeSegments[i].startsWith(':')) {
        const paramName = routeSegments[i].slice(1);
        params[paramName] = pathSegments[i];
      } else if (routeSegments[i] !== pathSegments[i]) {
        return null;
      }
    }

    return params;
  }
}

Here is an example of the home page implementing the IRoute interface defining a route.

import { Subscription } from 'rxjs';
import { ContentScriptConnection } from '../../lib/ContentScriptConnection';
import { IRoute } from '../../lib/Router';
import { API } from '../../lib/api/API';
import { OverlayManager } from './OverlayManager';

export class Home implements IRoute {
  connector: ContentScriptConnection; // Connects to the background script
  api: API;
  subscription: Subscription | null = null;
  overlayManager: OverlayManager | null = null;

  constructor(connector: ContentScriptConnection, api: API) {
    this.connector = connector;
    this.api = api;
    console.log('Home route initialized');
  }
  handleRoute = async (_params?: Record<string, string>): Promise<void> => {
    this.overlayManager = new OverlayManager();
  };

  handleMessage = (_message: any) => {};

  destroy = () => {
    if (this.overlayManager) {
      this.overlayManager.destroy();
    }
    console.log('Home route destroyed');
  };
}

It works very well. So maybe I'll refactor and publish it.

adam-s avatar Nov 28 '23 14:11 adam-s

this makes sense. thank you. one challenge is onHistoryStateUpdated might not fire for regular navigation like reload, etc.

the background has better event support (content script probably can only use mutationobserver), but the content sript is easier to access url unlike background, having to use many tab.url, etc.

devhandler avatar Nov 29 '23 02:11 devhandler

one challenge is onHistoryStateUpdated might not fire for regular navigation like reload,

  1. On reload or other types of navigation where the assists, i.e. js and css files, are loaded, the routing code is called when the injected content script is evaluated. Use window.location.href to initialize and map the url / href to the correct function to call.
    // Kick off the routing process by routing to the current URL.
    // Also update the previousUrl property.
    this.route(window.location.href);
    this.previousUrl = window.location.href;
  1. Initialize in the service worker a listener for webNavigation.onHistoryStateUpdated and send a custom event to the content script with information about the new url / href. This will handle the single page apps which use window.history.pushstate updating the url / href without reloading.
// Add the above function as a listener to chrome's webNavigation.onHistoryStateUpdated event.
chrome.webNavigation.onHistoryStateUpdated.addListener(
  sendUrlChangeToRobinhood
);
  1. Although the initial route is set manually when then content script is injected and evaluated by the browser engine, add a listener in the content script for the url / href changes detected by the service worker.
    // Add a listener for messages from the background script.
    // The background script will send a message when the URL changes.
    chrome.runtime.onMessage.addListener(
      (event: { type: string; message: string }) => {
        // If the received message indicates a URL change...
        if (event.type === 'URL_CHANGE') {
          // Check if the new URL is different from the previous URL.
          // This check is necessary because the onHistoryStateUpdated event can be triggered multiple times for a single navigation event.
          if (this.previousUrl !== event.message) {
            // Update the previousUrl property and route to the new URL.
            this.previousUrl = window.location.href;
            this.route(event.message);
          }
        }
      }
    );

This will set the route when the content script is injected and executed AND will leverage the service worker to listen for changes to the url / href passing the information back to the content script which has been injected and executed already to react mapping new url / href to different functions to call.

(note: This question might be better asked on StackOverflow or in a Reddit sub than here since it is a general question.)

adam-s avatar Nov 30 '23 00:11 adam-s