diff --git a/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts b/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts index 9ce663a69fe..88c106c5071 100644 --- a/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts +++ b/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts @@ -15,7 +15,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); @@ -58,7 +60,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); diff --git a/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts b/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts index 9ce663a69fe..88c106c5071 100644 --- a/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts +++ b/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts @@ -15,7 +15,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); @@ -58,7 +60,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); diff --git a/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts b/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts new file mode 100644 index 00000000000..6bbddfa5b9b --- /dev/null +++ b/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts @@ -0,0 +1,157 @@ +import chromium from "@sparticuz/chromium"; +import puppeteer, { Browser, Page } from "puppeteer-core"; +import extractPeLoaderDataFromHtml from "./extractPeLoaderDataFromHtml"; +import { RenderResult, RenderUrlCallableParams } from "./types"; +import { TagPathLink } from "~/types"; + +const windowSet = (page: Page, name: string, value: string | boolean) => { + page.evaluateOnNewDocument(` + Object.defineProperty(window, '${name}', { + get() { + return '${value}' + } + })`); +}; + +export interface File { + type: string; + body: any; + name: string; + meta: { + tags?: TagPathLink[]; + [key: string]: any; + }; +} + +export const defaultRenderUrlFunction = async ( + url: string, + params: RenderUrlCallableParams +): Promise => { + let browser!: Browser; + + try { + browser = await puppeteer.launch({ + args: chromium.args, + defaultViewport: chromium.defaultViewport, + executablePath: await chromium.executablePath(), + headless: chromium.headless, + ignoreHTTPSErrors: true + }); + + const browserPage = await browser.newPage(); + + // Can be used to add additional logic - e.g. skip a GraphQL query to be made when in pre-rendering process. + windowSet(browserPage, "__PS_RENDER__", true); + + const tenant = params.args.tenant; + if (tenant) { + console.log("Setting tenant (__PS_RENDER_TENANT__) to window object...."); + windowSet(browserPage, "__PS_RENDER_TENANT__", tenant); + } + + const locale = params.args.locale; + if (locale) { + console.log("Setting locale (__PS_RENDER_LOCALE__) to window object...."); + windowSet(browserPage, "__PS_RENDER_LOCALE__", locale); + } + + const renderResult: RenderResult = { + content: "", + meta: { + interceptedRequests: [], + apolloState: {}, + cachedData: { + apolloGraphQl: [], + peLoaders: [] + } + } + }; + + // Don't load these resources during prerender. + const skipResources = ["image"]; + await browserPage.setRequestInterception(true); + + browserPage.on("request", request => { + const issuedRequest = { + type: request.resourceType(), + url: request.url(), + aborted: false + }; + + if (skipResources.includes(issuedRequest.type)) { + issuedRequest.aborted = true; + request.abort(); + } else { + request.continue(); + } + + renderResult.meta.interceptedRequests.push(issuedRequest); + }); + + // TODO: should be a plugin. + browserPage.on("response", async response => { + const request = response.request(); + const url = request.url(); + if (url.includes("/graphql") && request.method() === "POST") { + const responses = (await response.json()) as Record; + const postData = JSON.parse(request.postData() as string); + const operations = Array.isArray(postData) ? postData : [postData]; + + for (let i = 0; i < operations.length; i++) { + const { query, variables } = operations[i]; + + // For now, we're doing a basic @ps(cache: true) match to determine if the + // cache was set true. In the future, if we start introducing additional + // parameters here, we should probably make this parsing smarter. + const mustCache = query.match(/@ps\((cache: true)\)/); + + if (mustCache) { + const data = Array.isArray(responses) ? responses[i].data : responses.data; + renderResult.meta.cachedData.apolloGraphQl.push({ + query, + variables, + data + }); + } + } + return; + } + }); + + // Load URL and wait for all network requests to settle. + await browserPage.goto(url, { waitUntil: "networkidle0" }); + + renderResult.content = await browserPage.content(); + + renderResult.meta.apolloState = await browserPage.evaluate(() => { + // @ts-expect-error + return window.getApolloState(); + }); + + renderResult.meta.cachedData.peLoaders = extractPeLoaderDataFromHtml(renderResult.content); + + return renderResult; + } finally { + if (browser) { + // We need to close all open pages first, to prevent browser from hanging when closed. + const pages = await browser.pages(); + for (const page of pages) { + await page.close(); + } + + // This is fixing an issue where the `await browser.close()` would hang indefinitely. + // The "inspiration" for this fix came from the following issue: + // https://github.com/Sparticuz/chromium/issues/85 + console.log("Killing browser process..."); + const childProcess = browser.process(); + if (childProcess) { + childProcess.kill(9); + } + + console.log("Browser process killed."); + } + } + + // There's no catch block here because errors are already being handled + // in the entrypoint function, located in `./index.ts` file. +}; diff --git a/packages/api-prerendering-service/src/render/preloadCss.ts b/packages/api-prerendering-service/src/render/preloadCss.ts new file mode 100644 index 00000000000..805967db6c7 --- /dev/null +++ b/packages/api-prerendering-service/src/render/preloadCss.ts @@ -0,0 +1,7 @@ +import { RenderResult } from "./types"; + +export const preloadCss = (render: RenderResult): void => { + const regex = / { + const fontsRequests = render.meta.interceptedRequests.filter( + req => req.type === "font" && req.url + ); + + const preloadLinks: string = Array.from(fontsRequests) + .map(req => { + return ``; + }) + .join("\n"); + + // Inject the preload tags into the section + render.content = render.content.replace("", `${preloadLinks}`); +}; diff --git a/packages/api-prerendering-service/src/render/preloadJs.ts b/packages/api-prerendering-service/src/render/preloadJs.ts new file mode 100644 index 00000000000..63fae20d827 --- /dev/null +++ b/packages/api-prerendering-service/src/render/preloadJs.ts @@ -0,0 +1,7 @@ +import { RenderResult } from "~/render/types"; + +export const preloadJs = (render: RenderResult): void => { + const regex = /