Chrome ללא GUI: תשובה לאתרי JS לעיבוד בצד השרת

איך משתמשים בממשקי API של Puppeteer כדי להוסיף יכולות עיבוד בצד השרת (SSR) לשרת אינטרנט Express. החלק הכי טוב הוא שבאפליקציה שלכם נדרשים שינויים קטנים מאוד בקוד. עבודה ללא ראש עושה את כל העבודה הקשה.

בכמה שורות קוד תוכלו לבצע SSR של כל דף ולקבל את הסימון הסופי שלו.

import puppeteer from 'puppeteer';

async function ssr(url) {
  const browser = await puppeteer.launch({headless: true});
  const page = await browser.newPage();
  await page.goto(url, {waitUntil: 'networkidle0'});
  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();
  return html;
}

למה כדאי להשתמש באפליקציית Headless Chrome?

ייתכן שתתעניין ב-Chrome ללא GUI אם:

מסגרות מסוימות, כמו Preact, כוללות כלים שמותאמים לעיבוד בצד השרת. אם ל-framework יש פתרון לעיבוד מראש, כדאי להישאר איתו במקום להוסיף את Puppeteer ו-Headless Chrome לתהליך העבודה.

סריקת האינטרנט המודרני

סורקים של מנועי חיפוש, פלטפורמות לשיתוף ברשתות חברתיות ואפילו דפדפנים הסתמכו בעבר אך ורק על תגי עיצוב של HTML סטטי כדי להוסיף את האינטרנט לאינדקס ולהציג תוכן. האינטרנט המודרני התפתח למשהו הרבה יותר שונה. אפליקציות מבוססות JavaScript נשארות כאן, כלומר במקרים רבים התוכן שלנו עלול להיות נסתר מפני כלי הסריקה.

ה-Googlebot, סורק החיפוש שלנו, מעבד JavaScript ומוודא שהוא לא פוגע בחוויה של המשתמשים שמבקרים באתר. יש כמה הבדלים ומגבלות שצריך להתחשב בהם כשמעצבים את הדפים והאפליקציות, כדי להתאים את האופן שבו סורקים יכולים לגשת לתוכן שלכם ולעבד אותו.

עיבוד מראש של דפים

כל הסורקים מבינים את HTML. כדי להבטיח שהסורקים יוכלו להוסיף את JavaScript לאינדקס, אנחנו זקוקים לכלי:

  • יודע איך להריץ את כל הסוגים של JavaScript המודרני וליצור HTML סטטי.
  • נשארים מעודכנים ככל שהאינטרנט מוסיף תכונות.
  • פועל עם עדכוני קוד מועטים, אם בכלל, עבור האפליקציה.

נשמע טוב, נכון? הכלי הזה הוא הדפדפן! ל-Chrome ללא דפדפן GUI לא משנה באיזו ספרייה, מסגרת או רשת כלים אתם משתמשים.

לדוגמה, אם האפליקציה שלכם מבוססת על Node.js, Puppeteer היא דרך קלה לעבוד עם Chrome ללא GUI.

נתחיל בדף דינמי שיוצר את ה-HTML שלו באמצעות JavaScript:

public/index.html

<html>
<body>
  <div id="container">
    <!-- Populated by the JS below. -->
  </div>
</body>
<script>
function renderPosts(posts, container) {
  const html = posts.reduce((html, post) => {
    return `${html}
      <li class="post">
        <h2>${post.title}</h2>
        <div class="summary">${post.summary}</div>
        <p>${post.content}</p>
      </li>`;
  }, '');

  // CAREFUL: this assumes HTML is sanitized.
  container.innerHTML = `<ul id="posts">${html}</ul>`;
}

(async() => {
  const container = document.querySelector('#container');
  const posts = await fetch('/posts').then(resp => resp.json());
  renderPosts(posts, container);
})();
</script>
</html>

פונקציית SSR

בשלב הבא, ניקח את הפונקציה ssr() מגרסאות קודמות ונשפר אותה קצת:

ssr.mjs

import puppeteer from 'puppeteer';

// In-memory cache of rendered pages. Note: this will be cleared whenever the
// server process stops. If you need true persistence, use something like
// Google Cloud Storage (https://firebase.google.com/docs/storage/web/start).
const RENDER_CACHE = new Map();

async function ssr(url) {
  if (RENDER_CACHE.has(url)) {
    return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
  }

  const start = Date.now();

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  try {
    // networkidle0 waits for the network to be idle (no requests for 500ms).
    // The page's JS has likely produced markup by this point, but wait longer
    // if your site lazy loads, etc.
    await page.goto(url, {waitUntil: 'networkidle0'});
    await page.waitForSelector('#posts'); // ensure #posts exists in the DOM.
  } catch (err) {
    console.error(err);
    throw new Error('page.goto/waitForSelector timed out.');
  }

  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();

  const ttRenderMs = Date.now() - start;
  console.info(`Headless rendered page in: ${ttRenderMs}ms`);

  RENDER_CACHE.set(url, html); // cache rendered page.

  return {html, ttRenderMs};
}

export {ssr as default};

השינויים העיקריים:

  • נוסף שמירה במטמון. שמירה במטמון של קוד ה-HTML שעבר עיבוד היא היתרון המשמעותי ביותר של זמני התגובה. כשנשלחת בקשה מחדש לדף, אתם נמנעים מהפעלת Chrome ללא דפדפן GUI. אדבר על אופטימיזציות אחרות בהמשך.
  • הוספת טיפול בסיסי בשגיאות אם הזמן הקצוב לטעינת הדף הסתיים.
  • הוספת שיחה אל page.waitForSelector('#posts'). כך ניתן לוודא שהפוסטים קיימים ב-DOM לפני שאנחנו שולחים את הדף עם המספר הסידורי.
  • מוסיפים מידע מדעי. רשמו כמה זמן לוקח לעבד את הדף ללא GUI, ולהחזיר את זמן הרינדור יחד עם ה-HTML.
  • מציבים את הקוד במודול בשם ssr.mjs.

שרת אינטרנט לדוגמה

לבסוף, הנה שרת האקספרס הקטן שמאחד את הכול. ה-handler הראשי מעבד מראש את כתובת ה-URL http://localhost/index.html (דף הבית) ומציג את התוצאה כתגובה שלו. המשתמשים רואים מיד פוסטים כשהם מגיעים לדף, כי עכשיו תגי העיצוב הסטטיים הם חלק מהתגובה.

server.mjs

import express from 'express';
import ssr from './ssr.mjs';

const app = express();

app.get('/', async (req, res, next) => {
  const {html, ttRenderMs} = await ssr(`${req.protocol}://${req.get('host')}/index.html`);
  // Add Server-Timing! See https://w3c.github.io/server-timing/.
  res.set('Server-Timing', `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`);
  return res.status(200).send(html); // Serve prerendered page as response.
});

app.listen(8080, () => console.log('Server started. Press Ctrl+C to quit'));

כדי להריץ את הדוגמה הזו, מתקינים את יחסי התלות (npm i --save puppeteer express) ומריצים את השרת באמצעות Node 8.5.0+ והדגל --experimental-modules:

הנה דוגמה לתגובה שנשלחה חזרה על ידי השרת הזה:

<html>
<body>
  <div id="container">
    <ul id="posts">
      <li class="post">
        <h2>Title 1</h2>
        <div class="summary">Summary 1</div>
        <p>post content 1</p>
      </li>
      <li class="post">
        <h2>Title 2</h2>
        <div class="summary">Summary 2</div>
        <p>post content 2</p>
      </li>
      ...
    </ul>
  </div>
</body>
<script>
...
</script>
</html>

מקרה שימוש מושלם ל-Server-Timing API החדש

ה-API של Server-Timing מעביר לדפדפן את מדדי הביצועים של השרת (כמו זמני תגובה ותגובה או חיפושי מסדי נתונים). קוד הלקוח יכול להשתמש במידע הזה כדי לעקוב אחרי הביצועים הכוללים של אפליקציית אינטרנט.

אחד מהתרחישים האידיאליים לתזמון שרת הוא לדווח כמה זמן לוקח ל-Chrome לבצע עיבוד מראש של דף ללא GUI! כדי לעשות זאת, פשוט מוסיפים את הכותרת Server-Timing לתגובת השרת:

res.set('Server-Timing', `Prerender;dur=1000;desc="Headless render time (ms)"`);

בלקוח, אפשר להשתמש ב-Performance API וב-PerformanceObserver כדי לגשת למדדים האלה:

const entry = performance.getEntriesByType('navigation').find(
    e => e.name === location.href);
console.log(entry.serverTiming[0].toJSON());

{
  "name": "Prerender",
  "duration": 3808,
  "description": "Headless render time (ms)"
}

תוצאות הביצועים

בתוצאות הבאות משולבות רוב האופטימיזציות של הביצועים שנדון בהמשך.

באחת מהאפליקציות (קוד) שלי, ל-Chrome נדרשת בערך שנייה אחת כדי לעבד את הדף בשרת ללא GUI. אחרי שהדף נשמר במטמון, כלי הפיתוח 3G Slow emulation מציב את FCP מהיר יותר ב-8.37 שניות מאשר הגרסה בצד הלקוח.

הצגת תמונה ראשונית במסך (FP)First Contentful Paint (FCP)
אפליקציה בצד הלקוח4 שנ' 11 שנ'
גרסת SSR2.3 שנ'בערך 2.3 שנ'

התוצאות האלה מבטיחות. משתמשים רואים תוכן משמעותי מהר יותר, כי הדף שעבר עיבוד בצד השרת לא מסתמך יותר על JavaScript כדי לטעון + מציג פוסטים.

מניעת השתייה מחדש

זוכרים שאמרתי "לא שינינו את הקוד של האפליקציה בצד הלקוח"? זה היה שקר.

אפליקציית Express מקבלת בקשה, משתמשת ב-Puppeteer כדי לטעון את הדף ללא GUI, ומציגה את התוצאה כתגובה. אבל יש בעיה עם המערך הזה.

אותו ה-JS שמופעל ב-Chrome ללא GUI בשרת פועל שוב כשהדפדפן של המשתמש טוען את הדף בממשק הקצה. יש לנו שני מקומות שבהם יוצרים תגי עיצוב. #doublerender!

אנחנו יכולים לעזור לך לפתור את הבעיה. אנחנו צריכים להודיע לדף שה-HTML שלו כבר נמצא במקום. הפתרון שמצאתי היה לבקש מה-JS של הדף לבדוק אם <ul id="posts"> כבר נמצא ב-DOM בזמן הטעינה. אם כן, אנחנו יודעים שהדף סומן כתוכן SSR ואנחנו יכולים להימנע מהוספת פוסטים מחדש. 👍

public/index.html

<html>
<body>
  <div id="container">
    <!-- Populated by JS (below) or by prerendering (server). Either way,
         #container gets populated with the posts markup:
      <ul id="posts">...</ul>
    -->
  </div>
</body>
<script>
...
(async() => {
  const container = document.querySelector('#container');

  // Posts markup is already in DOM if we're seeing a SSR'd.
  // Don't re-hydrate the posts here on the client.
  const PRE_RENDERED = container.querySelector('#posts');
  if (!PRE_RENDERED) {
    const posts = await fetch('/posts').then(resp => resp.json());
    renderPosts(posts, container);
  }
})();
</script>
</html>

אופטימיזציות

מלבד שמירה במטמון של התוצאות שעובדו, יש עוד הרבה אופטימיזציות מעניינות שאפשר לבצע ב-ssr(). חלק מהם מנצחים במהירות, ואחרים עשויים להיות ספקולטיביים יותר. יתרונות הביצועים שתראו תלויים בסופו של דבר בסוגי הדפים שאתם מעבדים מראש ובמורכבות של האפליקציה.

ביטול בקשות לא חיוניות

נכון לעכשיו, הדף כולו (וכל המשאבים שהוא מבקש) נטען ללא תנאי ב-Chrome ללא דפדפן GUI. עם זאת, אנחנו מתעניינים רק בשני דברים:

  1. תגי העיצוב שעברו רינדור.
  2. הבקשות של ה-JS שיצרו את תגי העיצוב האלה.

בקשות רשת שלא יוצרות DOM הן בזבוזות. משאבים כמו תמונות, גופנים, גיליונות סגנונות ומדיה לא משתתפים בבניית ה-HTML של דף. הם מעצבים ומשלימים את המבנה של הדף, אבל לא יוצרים אותו במפורש. עלינו להורות לדפדפן להתעלם מהמשאבים האלה. כך תוכלו להפחית את עומס העבודה ב-Chrome ללא דפדפן GUI, חוסך ברוחב פס ועשוי להאיץ את זמן העיבוד מראש בדפים גדולים.

פרוטוקול DevTools תומך בתכונה מתקדמת שנקראת יירוט רשת, שמאפשרת לשנות את הבקשות לפני שהדפדפן מנפיק אותן. Puppeteer תומך ביירוט רשת על ידי הפעלה של page.setRequestInterception(true) והאזנה לאירוע request של הדף. כך אנחנו יכולים לבטל בקשות למשאבים מסוימים ולאפשר לאחרים להמשיך בתהליך.

ssr.mjs

async function ssr(url) {
  ...
  const page = await browser.newPage();

  // 1. Intercept network requests.
  await page.setRequestInterception(true);

  page.on('request', req => {
    // 2. Ignore requests for resources that don't produce DOM
    // (images, stylesheets, media).
    const allowlist = ['document', 'script', 'xhr', 'fetch'];
    if (!allowlist.includes(req.resourceType())) {
      return req.abort();
    }

    // 3. Pass through all other requests.
    req.continue();
  });

  await page.goto(url, {waitUntil: 'networkidle0'});
  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();

  return {html};
}

הטמעת משאבים קריטיים

מקובל להשתמש בכלי build נפרדים (כמו gulp) כדי לעבד אפליקציה ולהטמיע CSS ו-JS קריטיים בדף בזמן ה-build. הפעולה הזו יכולה לזרז את הצגת הנתונים בעלי המשמעות הראשונה כי הדפדפן שולח פחות בקשות במהלך הטעינה הראשונית של הדף.

במקום כלי נפרד לפיתוח, אפשר להשתמש בדפדפן ככלי ה-build שלכם! אנחנו יכולים להשתמש ב-Puppeteer כדי לשנות את ה-DOM של הדף, להוסיף סגנונות, JavaScript או כל דבר אחר שרוצים להוסיף לדף לפני העיבוד מראש.

הדוגמה הזו מראה איך ליירט תגובות לגיליונות סגנונות מקומיים ולהטמיע את המשאבים האלה בדף כתגי <style>:

ssr.mjs

import urlModule from 'url';
const URL = urlModule.URL;

async function ssr(url) {
  ...
  const stylesheetContents = {};

  // 1. Stash the responses of local stylesheets.
  page.on('response', async resp => {
    const responseUrl = resp.url();
    const sameOrigin = new URL(responseUrl).origin === new URL(url).origin;
    const isStylesheet = resp.request().resourceType() === 'stylesheet';
    if (sameOrigin && isStylesheet) {
      stylesheetContents[responseUrl] = await resp.text();
    }
  });

  // 2. Load page as normal, waiting for network requests to be idle.
  await page.goto(url, {waitUntil: 'networkidle0'});

  // 3. Inline the CSS.
  // Replace stylesheets in the page with their equivalent <style>.
  await page.$$eval('link[rel="stylesheet"]', (links, content) => {
    links.forEach(link => {
      const cssText = content[link.href];
      if (cssText) {
        const style = document.createElement('style');
        style.textContent = cssText;
        link.replaceWith(style);
      }
    });
  }, stylesheetContents);

  // 4. Get updated serialized HTML of page.
  const html = await page.content();
  await browser.close();

  return {html};
}

This code:

  1. Use a page.on('response') handler to listen for network responses.
  2. Stashes the responses of local stylesheets.
  3. Finds all <link rel="stylesheet"> in the DOM and replaces them with an equivalent <style>. See page.$$eval API docs. The style.textContent is set to the stylesheet response.

Auto-minify resources

Another trick you can do with network interception is to modify the responses returned by a request.

As an example, say you want to minify the CSS in your app but also want to keep the convenience having it unminified when developing. Assuming you've setup another tool to pre-minify styles.css, one can use Request.respond() to rewrite the response of styles.css to be the content of styles.min.css.

ssr.mjs

import fs from 'fs';

async function ssr(url) {
  ...

  // 1. Intercept network requests.
  await page.setRequestInterception(true);

  page.on('request', req => {
    // 2. If request is for styles.css, respond with the minified version.
    if (req.url().endsWith('styles.css')) {
      return req.respond({
        status: 200,
        contentType: 'text/css',
        body: fs.readFileSync('./public/styles.min.css', 'utf-8')
      });
    }
    ...

    req.continue();
  });
  ...

  const html = await page.content();
  await browser.close();

  return {html};
}

שימוש חוזר במופע יחיד של Chrome בעיבוד

השקת דפדפן חדש לכל עיבוד מראש יוצרת תקורה רבה. במקום זאת, יכול להיות שתרצו להפעיל מכונה אחת ולהשתמש בה שוב כדי לעבד מספר דפים.

Puppeteer יכול להתחבר מחדש למכונה קיימת של Chrome, באמצעות קריאה ל-puppeteer.connect() והעברת כתובת ה-URL לניפוי באגים מרחוק של המכונה. כדי לשמור מכונה של דפדפן שתהיה רציפה, אנחנו יכולים להעביר את הקוד שמפעיל את Chrome מהפונקציה ssr() לשרת ה-Express:

server.mjs

import express from 'express';
import puppeteer from 'puppeteer';
import ssr from './ssr.mjs';

let browserWSEndpoint = null;
const app = express();

app.get('/', async (req, res, next) => {
  if (!browserWSEndpoint) {
    const browser = await puppeteer.launch();
    browserWSEndpoint = await browser.wsEndpoint();
  }

  const url = `${req.protocol}://${req.get('host')}/index.html`;
  const {html} = await ssr(url, browserWSEndpoint);

  return res.status(200).send(html);
});

ssr.mjs

import puppeteer from 'puppeteer';

/**
 * @param {string} url URL to prerender.
 * @param {string} browserWSEndpoint Optional remote debugging URL. If
 *     provided, Puppeteer's reconnects to the browser instance. Otherwise,
 *     a new browser instance is launched.
 */
async function ssr(url, browserWSEndpoint) {
  ...
  console.info('Connecting to existing Chrome instance.');
  const browser = await puppeteer.connect({browserWSEndpoint});

  const page = await browser.newPage();
  ...
  await page.close(); // Close the page we opened here (not the browser).

  return {html};
}

דוגמה: משימת cron לעיבוד מראש מדי פעם

באפליקציית מרכז הבקרה של App Engine, הגדרתי handler של cron כדי לבצע עיבוד מחדש מדי פעם של הדפים המובילים באתר. כך המבקרים תמיד יוכלו לראות תוכן מהיר ועדכני, להימנע מצפייה ב'עלות ההפעלה' של עיבוד חדש מראש. במקרה כזה, לא צריך ליצור כמה מכונות של Chrome. במקום זאת, אני משתמש במופע של דפדפן משותף כדי לעבד כמה דפים בבת אחת:

import puppeteer from 'puppeteer';
import * as prerender from './ssr.mjs';
import urlModule from 'url';
const URL = urlModule.URL;

app.get('/cron/update_cache', async (req, res) => {
  if (!req.get('X-Appengine-Cron')) {
    return res.status(403).send('Sorry, cron handler can only be run as admin.');
  }

  const browser = await puppeteer.launch();
  const homepage = new URL(`${req.protocol}://${req.get('host')}`);

  // Re-render main page and a few pages back.
  prerender.clearCache();
  await prerender.ssr(homepage.href, await browser.wsEndpoint());
  await prerender.ssr(`${homepage}?year=2018`);
  await prerender.ssr(`${homepage}?year=2017`);
  await prerender.ssr(`${homepage}?year=2016`);
  await browser.close();

  res.status(200).send('Render cache updated!');
});

הוספתי גם ייצוא של clearCache() אל ssr.js:

...
function clearCache() {
  RENDER_CACHE.clear();
}

export {ssr, clearCache};

שיקולים נוספים

יצירת אות לדף: "מתבצע עיבוד ללא GUI"

כשהדף שלכם עובר עיבוד על ידי Chrome ללא GUI בשרת, יכול להיות שכדאי ללוגיקה בצד הלקוח של הדף לדעת את זה. באפליקציה שלי, השתמשתי בהוק הזה כדי "להשבית" חלקים בדף שלא ממלאים תפקיד בעיבוד סימון הפוסטים. לדוגמה, השבתתי את הקוד שטוען באופן הדרגתי את firebase-auth.js. אין משתמש נכנס!

הוספת הפרמטר ?headless לכתובת ה-URL של העיבוד היא דרך פשוטה להעניק לדף 'hook':

ssr.mjs

import urlModule from 'url';
const URL = urlModule.URL;

async function ssr(url) {
  ...
  // Add ?headless to the URL so the page has a signal
  // it's being loaded by headless Chrome.
  const renderUrl = new URL(url);
  renderUrl.searchParams.set('headless', '');
  await page.goto(renderUrl, {waitUntil: 'networkidle0'});
  ...

  return {html};
}

בדף אנחנו מחפשים את הפרמטר הזה:

public/index.html

<html>
<body>
  <div id="container">
    <!-- Populated by the JS below. -->
  </div>
</body>
<script>
...

(async() => {
  const params = new URL(location.href).searchParams;

  const RENDERING_IN_HEADLESS = params.has('headless');
  if (RENDERING_IN_HEADLESS) {
    // Being rendered by headless Chrome on the server.
    // e.g. shut off features, don't lazy load non-essential resources, etc.
  }

  const container = document.querySelector('#container');
  const posts = await fetch('/posts').then(resp => resp.json());
  renderPosts(posts, container);
})();
</script>
</html>

מניעת ניפוח של צפיות בדפים ב-Analytics

חשוב להיזהר אם משתמשים ב-Analytics באתר. עיבוד מראש של דפים עלול לגרום לניפוח מספר הצפיות בדף. באופן ספציפי, אפשר לראות פי 2 ממספר ההתאמות: התאמה אחת כש-Chrome מעבד את הדף ללא GUI, והוספה נוספת כשהדפדפן של המשתמש מעבד אותו.

אז מה התיקון? השתמשו ביירוט רשת כדי לבטל כל בקשה שמנסה לטעון את ספריית Analytics.

page.on('request', req => {
  // Don't load Google Analytics lib requests so pageviews aren't 2x.
  const blockist = ['www.google-analytics.com', '/gtag/js', 'ga.js', 'analytics.js'];
  if (blocklist.find(regex => req.url().match(regex))) {
    return req.abort();
  }
  ...
  req.continue();
});

ביקורים בדף אף פעם לא נרשמים אם הקוד לעולם לא נטען. בום 💥.

לחלופין, תוכלו להמשיך לטעון את הספריות של Analytics כדי לקבל תובנות לגבי מספר העיבודים מראש שמניבים השרת.

סיכום

באמצעות Puppeteer אפשר להפעיל בקלות דפים בצד השרת באמצעות דפדפן Chrome ללא דפדפן גרפי, בשרת האינטרנט. ה'תכונה' האהובה עליי בגישה הזו היא לשפר את ביצועי הטעינה ואת היכולת להוסיף לאינדקס את האפליקציה ללא שינויים משמעותיים בקוד!

אם אתם סקרנים לראות אפליקציה פעילה שמשתמשת בטכניקות שמתוארות כאן, תוכלו לקרוא על האפליקציה devwebfeed.

נספח

דיון על יצירות קודמות

קשה לעבד אפליקציות בצד הלקוח בצד השרת. כמה קשה? פשוט ראו כמה חבילות npm כתבו במיוחד לנושא. יש אינספור תבניות, tools ושירותים שאפשר להיעזר בהם כדי לפתח אפליקציות SSRing JS.

JavaScript איזומורפי / אוניברסלי

המושג Universal JavaScript פירושו: אותו קוד שרץ בשרת פועל גם בלקוח (הדפדפן). משתפים את הקוד בין השרת והלקוח, וכולם מרגישים של זן.

דפדפן ללא GUI מאפשר 'איזומורפי JS' בין שרת ללקוח. זו אפשרות נהדרת אם הספרייה שלכם לא פועלת בשרת (צומת).

כלים לעיבוד מראש

קהילת הצמתים בנתה המון כלים להתמודדות עם אפליקציות SSR JS. אין הפתעות! באופן אישי, גיליתי ש-YMMV בכמה מהכלים האלה, אז בהחלט חשוב לעשות שיעורי בית לפני שתתחייבו להשתמש בהם. לדוגמה, חלק מכלי ה-SSR הם ישנים יותר ולא משתמשים ב-Chrome ללא דפדפן GUI (או כל דפדפן ללא GUI). במקום זאת, הם משתמשים ב-PantomJS (שנקרא גם Safari ישן), ולכן הדפים לא יוצגו כראוי אם הם משתמשים בתכונות חדשות יותר.

אחד היוצאים מן הכלל הוא עיבוד מראש. העיבוד מראש מעניין כי הוא משתמש ב-Chrome ללא ממשק גרפי, ונוסף אליו תווכה ל-Express:

const prerender = require('prerender');
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();

חשוב לציין ש'עיבוד מראש' משאיר את פרטי ההורדה וההתקנה של Chrome בפלטפורמות שונות. לפעמים די מסובך לכתוב נכון, וזו אחת הסיבות לכך ש-Puppeteer עושה בשבילך. נתקלתי גם בשירות האונליין בעיבוד חלק מהאפליקציות:

chromestatus מוצג בדפדפן
האתר מרונדר בדפדפן
chromestatus מרונדר על ידי עיבוד מראש
אותו אתר עבר רינדור על ידי prerender.io