Skip to content

SOLVED!!! Follow-up to 'Always-On NODE Relay with rtcPolyfill': Using Headless Chromium to Run Browser Apps as Persistent Nodes / Virtual Always-On User #122

@worldpeaceenginelabs

Description

@worldpeaceenginelabs

Hello everyone,

I was thrilled to see the RTC polyfill working (❤) —it meant we could finally have an always-on node. 🙏

But then reality hit: if it's a browser app, the whole architecture has to change. IndexedDB doesn’t work in Node, so I had to switch to something like Level. A few other things broke too, which meant a full refactor.

But then I had an idea: What if we could use our existing Trystero-based browser apps as-is for always-on nodes?

Two solutions came up:

  1. Wrap it in Electron. (can run on a server, but mostly for running our app as desktop app/relay)

  2. Run it via Puppeteer—a headless Chromium browser on a Node server.

Honestly, option two feels like the real winner.


Here’s a minimal setup to run your browser app as an always-on node using Puppeteer. Just follow these simple steps:

Take the contents of your build folder (the output of your bundler, e.g., Rollup, Vite, Svelte, etc.).

Add the puppeteer-runner.js script and package.json (example below) into that same folder. (where your index.html is, usually top folder)

Run the script with Node.

That’s it—your app will run in a headless Chromium browser, acting as a persistent node without changing any of your existing browser code.
PS: I let the server restart all 5min to ensure its available. Should it crash, stuck or whatever inside that 5min, it also restarts. You can adjust this to whatever your scenario needs. 🙏

puppeteer-runner.js

import puppeteer from 'puppeteer';
import { createServer } from 'http';
import { readFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const serveDist = async () => {
  const server = createServer(async (req, res) => {
    const filePath = req.url === '/' ? '/index.html' : req.url;

    const mimeTypes = {
      '.html': 'text/html',
      '.js': 'application/javascript',
      '.css': 'text/css',
      '.json': 'application/json',
      '.wasm': 'application/wasm',
      '.png': 'image/png',
      '.jpg': 'image/jpeg',
      '.svg': 'image/svg+xml',
    };

    try {
      const fullPath = path.join(__dirname, filePath);
      const file = await readFile(fullPath);
      const ext = path.extname(filePath);
      const contentType = mimeTypes[ext] || 'application/octet-stream';
      res.writeHead(200, { 'Content-Type': contentType });
      res.end(file);
    } catch {
      res.writeHead(404);
      res.end('Not found');
    }
  });

  return new Promise((resolve) => {
    server.listen(3000, () => {
      console.log('🚀 Local server running at http://localhost:3000');
      resolve(server);
    });
  });
};

const launchBrowser = async () => {
  const browser = await puppeteer.launch({
    headless: 'new',
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--use-fake-ui-for-media-stream',
      '--use-fake-device-for-media-stream',
      '--enable-webgl',
      '--ignore-certificate-errors',
      '--allow-insecure-localhost',
      '--enable-features=WebRTC-HW-Decoding,WebRTC-HW-Encoding',
    ],
    defaultViewport: null,
    userDataDir: path.join(__dirname, 'puppeteer-profile'),
  });

  const page = await browser.newPage();

  page.on('console', msg => console.log('[PAGE]', msg.text()));
  page.on('error', err => console.error('[PAGE ERROR]', err));
  page.on('pageerror', err => console.error('[PAGE EXCEPTION]', err));

  await page.goto('http://localhost:3000', { waitUntil: 'networkidle0' });
  console.log('✅ App loaded in headless browser');

  return { browser };
};

const runLoop = async () => {
  await serveDist();

  while (true) {
    let browser = null;
    try {
      console.log('🔁 Starting new Puppeteer session...');
      const result = await launchBrowser();
      browser = result.browser;

      // Wait for 5 minutes or until crash
      await new Promise((resolve, reject) => {
        const timeout = setTimeout(resolve, 5 * 60 * 1000);
        browser.on('disconnected', () => {
          clearTimeout(timeout);
          reject(new Error('Browser disconnected unexpectedly'));
        });
      });

      console.log('🧹 5 minutes passed, closing browser...');
    } catch (err) {
      console.error('💥 Error during session:', err.message);
    } finally {
      if (browser && browser.isConnected()) {
        try {
          await browser.close();
        } catch (err) {
          console.warn('⚠️ Error closing browser:', err.message);
        }
      }
      console.log('🔄 Restarting session...');
    }
  }
};

runLoop();

package.json

{
    "name": "your-apps-name",
    "version": "1.0.0",
    "description": "Your Trystero Browser App on Node Server",
    "type": "module",
    "main": "puppeteer-runner.js",
    "scripts": {
      "start": "node puppeteer-runner.js"
    },
    "dependencies": {
      "puppeteer": "^24.7.2"
    }
  }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions