xylabs/sdk-meta-server-nodejs

View on GitHub
src/modules/metaServer/lib/page/usePage/useSpaPage.ts

Summary

Maintainability
A
2 hrs
Test Coverage
/* eslint-disable max-statements */
import { Mutex } from 'async-mutex'
import { Browser, Page, Viewport, WaitForOptions } from 'puppeteer'

import { defaultViewportSize, useBrowser } from '../../browser'
import { PageRenderingOptions } from '../PageRenderingOptions'
import { timeout, waitUntil } from './defaults'
import { getBrowserPage } from './getBrowserPage'

const viewPortDefaults: Viewport = {
  ...defaultViewportSize,
  deviceScaleFactor: 1,
  hasTouch: false,
  isLandscape: false,
  isMobile: true, // So we can render as lean as possible
}

export const useSpaPageRenderingOptions: PageRenderingOptions = {
  viewportSize: viewPortDefaults,
}

/**
 * Options for waiting for navigation for typical SPA pages
 * (like React).
 * https://cloudlayer.io/blog/puppeteer-waituntil-options/
 */
export const useSpaPageWaitForOptions: WaitForOptions = {
  timeout,
  waitUntil,
  // waitUntil: 'domcontentloaded',
}

let _browser: Browser | undefined
let _page: Page | undefined

const pageMutex = new Mutex()
const reusePage = true
const reuseBrowser = true

/**
 * Helper for navigating to a url within a SPA (like React). This
 * helper first navigates to the root, then uses the browser history
 * to navigate to the relative path. This also prevents an infinite
 * cycle where:
 *  - the the server intercepts the request for the route
 *  - the server attempts to render the route by making a request for the route
 *  - the server intercepts it's own request for the route
 * by first navigating to the root, then using the browser history
 * to navigate to the relative path within the app.
 * @param url The url to navigate to
 * @param pageCallback Function to execute using the browser page
 * @param browserOptions options for the browser
 * @param waitForOptions options for waiting for page navigation
 * @returns The result of the pageCallback
 */
export const useSpaPage = async <T>(
  url: string,
  pageCallback: (page: Page) => Promise<T> | T,
  browserOptions: Viewport = viewPortDefaults,
  _waitForOptions: WaitForOptions = useSpaPageWaitForOptions,
  tryCount = 2,
): Promise<T | undefined> => {
  await pageMutex.acquire()
  try {
    if (tryCount < 1) {
      return undefined
    }
    const parsed = new URL(url)
    const { origin, pathname, search } = parsed
    const relativePath = search ? `${pathname}${search}` : pathname
    const start = Date.now()
    _browser = _browser ?? (await useBrowser(browserOptions))
    _page = _page ?? (await getBrowserPage(_browser, origin))
    // First navigate to the root
    //await page.goto(origin)

    // Wait for the div with id "root" to have at least one child.
    // This assumes the child is a direct descendant (using '>').
    // This assumes React will mount in a div with id="root" .
    // await page.waitForSelector('#root > *', { timeout })

    // React Router DOM seems to not listen to pushState but does
    // listen to back.  So we push state to the desired path twice,
    // then go back once to trigger the navigation.

    console.log(`Trying relative path: ${relativePath}`)

    await _page.evaluate((relativePath) => window.history.pushState(null, '', relativePath), relativePath)
    await _page.evaluate((relativePath) => window.history.pushState(null, '', relativePath), relativePath)
    await _page.evaluate(() => window.history.back())

    const duration = Date.now() - start

    console.log(`useSpaPage:profile: ${duration}ms`)

    return await pageCallback(_page)
  } catch (err) {
    //if it crashed, we restart the browser
    await _page?.close()
    await _browser?.close()
    _page = undefined
    _browser = undefined
    pageMutex.release()
    console.log(err)
    //we retry with a fresh browser and page
    const result = await useSpaPage(url, pageCallback, browserOptions, _waitForOptions, tryCount - 1)
    await pageMutex.acquire()
    return result
  } finally {
    if (!reusePage || !reuseBrowser) {
      await _page?.close()
      _page = undefined
    }
    if (!reuseBrowser) {
      await _browser?.close()
      _browser = undefined
    }
    pageMutex.release()
  }
}