Skip to content

Service Workers in Node Context

Wait - Service Workers are a browser API You use them in Progressive Web Apps to intercept network requests , cache assets , and enable offline mode In Node.js , we don't have a DOM , we don't have fetch interception in the same way , and we certainly don't have a browser So what does "PWA" mean for a Node developer?

Two things: building the backend for PWAs , and building desktop apps with Node

The Node.js Role in PWAs

A PWA needs a backend - API endpoints , push notifications , authentication Node.js is the most common backend for PWAs because it handles Web Push , WebSocket connections , and high-concurrency API requests efficiently

// PWA push notification endpoint
const webpush = require('web-push')

webpush.setVapidDetails(
  'mailto:admin@myapp.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
)

app.post('/api/subscribe', (req, res) => {
  const subscription = req.body
  // Store subscription in database for later use

  const payload = JSON.stringify({
    title: 'Welcome!',
    body: 'Notifications enabled',
    icon: '/icon.png'
  })

  webpush.sendNotification(subscription, payload)
    .then(() => res.json({ ok: true }))
    .catch(err => {
      // Subscription expired or invalid - remove from DB
      res.status(410).json({ error: 'subscription expired' })
    })
})

PWAs need HTTPS (required for Service Workers) , push endpoints , and service worker files served from your Node server

// Serve service worker from Node
app.get('/sw.js', (req, res) => {
  res.set('Content-Type', 'application/javascript')
  res.set('Service-Worker-Allowed', '/')
  res.sendFile(path.join(__dirname, 'public', 'sw.js'))
})

// Cache-first strategy - serve from cache, update in background
// This goes in sw.js (browser-side), served by Node

Electron - Node.js + Chromium

Electron wraps Node.js and Chromium into a desktop application Your app has access to the full Node.js API (file system , native addons , child processes) AND the full browser API (DOM , Canvas , WebGL , Service Workers)

// main.js - Electron main process
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')

let mainWindow

app.whenReady().then(() => {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false  // security: don't expose Node in renderer
    }
  })

  mainWindow.loadFile('index.html')
})

// IPC - secure communication between main and renderer
ipcMain.handle('read-file', async (event, filePath) => {
  const fs = require('fs/promises')
  try {
    const content = await fs.readFile(filePath, 'utf-8')
    return { success: true, content }
  } catch (err) {
    return { success: false, error: err.message }
  }
})
// preload.js - bridge between main process and renderer
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  readFile: (path) => ipcRenderer.invoke('read-file', path),
  getPlatform: () => process.platform,
  getVersion: () => process.versions.node
})
// renderer.js - runs in browser context (no direct Node access)
const { readFile } = window.electronAPI

async function loadConfig() {
  const result = await readFile('/etc/config.json')
  if (result.success) {
    document.getElementById('config').textContent = result.content
  }
}

contextIsolation: true is non-negotiable Never disable contextIsolation - it prevents the renderer from accessing Node.js APIs directly The preload script is your security boundary - expose only what the renderer needs

Building a PWA Backend with Node

// Complete PWA backend stack
const express = require('express')
const webpush = require('web-push')
const sqlite3 = require('better-sqlite3')

const app = express()
const db = new sqlite3('subscriptions.db')

app.use(express.json())

// Serve static PWA files
app.use(express.static('public'))

// Register push subscription
app.post('/api/push/subscribe', (req, res) => {
  const { endpoint, keys } = req.body

  const stmt = db.prepare(
    'INSERT OR REPLACE INTO subscriptions (endpoint, p256dh, auth) VALUES (?, ?, ?)'
  )
  stmt.run(endpoint, keys.p256dh, keys.auth)

  res.json({ ok: true })
})

// Send push notification to all subscribers
app.post('/api/push/send', async (req, res) => {
  const { title, body } = req.body
  const rows = db.prepare('SELECT * FROM subscriptions').all()

  const results = await Promise.allSettled(
    rows(sub => webpush.sendNotification({
      endpoint: sub.endpoint,
      keys: { p256dh: sub.p256dh, auth: sub.auth }
    }, JSON.stringify({ title, body })))
  )

  // Remove invalid subscriptions
  results.forEach((result, i) => {
    if (result.status === 'rejected' && result.reason.statusCode === 410) {
      db.prepare('DELETE FROM subscriptions WHERE endpoint = ?')
        .run(rows[i].endpoint)
    }
  })

  res.json({ sent: results.filter(r => r.status === 'fulfilled').length })
})

// Push notification - not a PWA without HTTPS
// Use Let's Encrypt + certbot for TLS
app.listen(443, () => console.log('PWA backend running'))

PWA checklist for Node backend: - HTTPS with valid TLS certificate - Push notification endpoints (VAPID keys) - Service worker file served with correct MIME type - Manifest.json with app metadata - Offline support via Cache API (browser-side)

Security in Electron/Desktop Apps

// DANGER - never disable webSecurity
const win = new BrowserWindow({
  webPreferences: {
    webSecurity: false,   // XSS becomes RCE - NEVER
    nodeIntegration: true, // renderer gets full Node access - NEVER
    contextIsolation: false // renderer shares scope - NEVER
  }
})

Electron apps bundle both JavaScript engines - if an attacker XSSes your renderer , they have access to everything the preload script exposes

// SECURE - minimal preload bridge
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('api', {
  getData: () => ipcRenderer.invoke('get-data'),
  saveFile: (name, content) => ipcRenderer.invoke('save-file', name, content)
})

Validate all IPC inputs on the main process side The main process has full Node.js access - don't let the renderer trick it into deleting files

Prerequisites


next -> adv_05_cli_apps.md