Skip to main content
Opal Editor leverages service workers to enable powerful offline-first capabilities, including content rendering, image optimization, and local file serving without any backend server.

Architecture Overview

The service worker acts as a programmable network proxy that intercepts all HTTP requests from the editor and handles them locally.
// Service worker registration
await navigator.serviceWorker.register('/sw.js', {
  scope: '/',
  updateViaCache: 'none',
});

How It Works

  1. Registration: Service worker registers on first load
  2. Interception: All fetch requests are intercepted
  3. Local Processing: Requests are handled using local storage
  4. Response: Results returned without touching the network
Service worker request flow diagram

Request Routing

Opal uses Hono, a lightweight web framework, to route requests within the service worker:
// From sw-hono.ts
const app = new Hono();

// Route definitions
app.get('/api/workspace/:workspaceName/file/*', handleFileRequest);
app.get('/api/workspace/:workspaceName/image/*', handleImageRequest);
app.post('/api/workspace/:workspaceName/search', handleWorkspaceSearch);
app.get('/download.zip', handleDownloadRequest);

Request Types

The service worker handles several categories of requests:

File Serving

Serves workspace files directly from IndexedDB/OPFS

Image Processing

Automatic WebP conversion and caching

Markdown Rendering

Server-side markdown to HTML conversion

Search Operations

Full-text and filename search across workspace

Key Features

1. Offline File Serving

Serve files from local storage as if they came from a server:
// Request a workspace file
fetch('/api/workspace/my-blog/file/posts/hello.md')
  .then(res => res.text())
  .then(content => {
    // File served from local storage
    console.log(content);
  });
The service worker:
  • Resolves the workspace name from the URL
  • Loads the workspace from local storage
  • Reads the file from the disk
  • Returns it as an HTTP response

2. Markdown Rendering

Render markdown to HTML without a backend:
// From handleMarkdownRender.ts
app.get('/api/render/markdown', async (c) => {
  const { workspaceName, documentId, editId } = c.req.query();
  
  // Load workspace from local storage
  const workspace = await SWWStore.getWorkspace(workspaceName);
  
  // Get document content
  const content = await workspace.readFile(documentId);
  
  // Render to HTML
  const html = await renderMarkdown(content);
  
  return c.html(html);
});
Features:
  • Syntax highlighting for code blocks
  • GitHub-flavored markdown
  • Table of contents generation
  • Image URL resolution
  • Link transformations

3. Image Optimization

Automatic image format conversion and caching:
// From handleImageRequest.ts  
app.get('/api/workspace/:workspaceName/image/*', async (c) => {
  const imagePath = c.req.param('*');
  const workspace = await resolveWorkspace(c);
  
  // Read original image
  const imageData = await workspace.readFile(imagePath);
  
  // Convert to WebP for better compression
  const webpData = await convertToWebP(imageData);
  
  // Cache the converted image
  await cache.put(imagePath, webpData);
  
  return c.body(webpData, 200, {
    'Content-Type': 'image/webp',
    'Cache-Control': 'max-age=31536000'
  });
});
Full-text search across all workspace files:
// From handleWorkspaceSearch.ts
app.post('/api/workspace/:workspaceName/search', async (c) => {
  const { searchTerm, regexp } = await c.req.json();
  const workspace = await resolveWorkspace(c);
  
  // Search all text files
  const results = [];
  for await (const { filePath, text } of workspace.disk.scan()) {
    if (regexp) {
      const regex = new RegExp(searchTerm, 'gi');
      const matches = [...text.matchAll(regex)];
      if (matches.length) {
        results.push({ filePath, matches });
      }
    } else {
      if (text.includes(searchTerm)) {
        results.push({ filePath });
      }
    }
  }
  
  return c.json(results);
});
Search capabilities:
  • Full-text content search
  • Filename search with fuzzy matching
  • Regular expression support
  • Streaming results for large workspaces
  • Case-insensitive matching

5. ZIP Export

Export entire workspace as a ZIP file:
// From handleDownloadRequest.ts
app.get('/download.zip', async (c) => {
  const { workspaceName, password, encryption } = c.req.query();
  const workspace = await resolveWorkspace(c);
  
  // Create ZIP stream
  const zipStream = new ReadableStream({
    async start(controller) {
      for await (const node of workspace.disk.fileTree.iterator()) {
        if (node.isFile()) {
          const content = await workspace.readFile(node.path);
          controller.enqueue({
            path: node.path,
            content: content
          });
        }
      }
      controller.close();
    }
  });
  
  // Optionally encrypt
  if (password) {
    return encryptedZipStream(zipStream, password, encryption);
  }
  
  return c.body(zipStream, 200, {
    'Content-Type': 'application/zip',
    'Content-Disposition': `attachment; filename="${workspaceName}.zip"`
  });
});

Request Signaling

Service workers can’t directly communicate with the main thread, so Opal uses a request signaling system:
// From RequestSignals.tsx
class RequestSignals {
  // Signal a request to the service worker
  signal(requestId: string, data: any) {
    const event = new CustomEvent('REQ_SIGNAL', {
      detail: { requestId, data }
    });
    self.dispatchEvent(event);
  }
  
  // Wait for response
  async waitForResponse(requestId: string): Promise<any> {
    return new Promise((resolve) => {
      const handler = (event: CustomEvent) => {
        if (event.detail.requestId === requestId) {
          resolve(event.detail.data);
          self.removeEventListener('REQ_RESPONSE', handler);
        }
      };
      self.addEventListener('REQ_RESPONSE', handler);
    });
  }
}
Use cases:
  • Long-running operations (search, export)
  • Progress reporting
  • Cancellation support
  • Bidirectional communication

Service Worker Lifecycle

Installation

self.addEventListener('install', (event) => {
  console.log('Service worker installing...');
  // Skip waiting to activate immediately
  event.waitUntil(self.skipWaiting());
});

Activation

self.addEventListener('activate', (event) => {
  console.log('Service worker activated');
  // Take control of all pages immediately
  event.waitUntil(self.clients.claim());
});

Fetch Handling

self.addEventListener('fetch', (event) => {
  // Use Hono to handle the request
  event.respondWith(handle(event.request));
});

Performance Optimization

Caching Strategy

Opal uses multiple cache levels:
// Cache layers
1. Memory cache (fastest, volatile)
2. Cache API (fast, persistent)
3. IndexedDB/OPFS (persistent, source of truth)

Streaming Responses

Large files are streamed to avoid memory issues:
app.get('/api/workspace/:name/file/*', async (c) => {
  const filePath = c.req.param('*');
  
  // Stream large files
  const stream = await workspace.createReadStream(filePath);
  
  return c.body(stream, 200);
});

Background Sync

Service workers enable background synchronization:
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-git') {
    event.waitUntil(syncWithGitRemote());
  }
});

Debugging

DevTools Integration

View service worker activity in Chrome DevTools:
  1. Open DevTools → Application → Service Workers
  2. Check “Update on reload” during development
  3. Use “Unregister” to clear the service worker

Logging

Opal includes comprehensive service worker logging:
// From logger.ts
const logger = {
  log: (...args) => console.log('[SW]', ...args),
  error: (...args) => console.error('[SW]', ...args),
  debug: (...args) => console.debug('[SW]', ...args)
};
Logs include:
  • Request routing
  • File operations
  • Performance metrics
  • Error details

Browser Compatibility

Service workers require:
  • HTTPS (or localhost for development)
  • Modern browser (Chrome 40+, Firefox 44+, Safari 11.1+, Edge 17+)
Opal gracefully degrades when service workers aren’t available:
// From BrowserAbility.ts
const canUseServiceWorker = async () => {
  if (!('serviceWorker' in navigator)) {
    return false;
  }
  
  try {
    await setupServiceWorker();
    return true;
  } catch {
    return false;
  }
};

Best Practices

Always call skipWaiting() and clients.claim() to ensure users get the latest version:
self.addEventListener('install', (event) => {
  event.waitUntil(self.skipWaiting());
});
Let the main app handle navigation requests:
if (request.mode === 'navigate') {
  return fetch(request);
}
Always provide fallbacks for failed operations:
try {
  return await handleRequest(request);
} catch (error) {
  return new Response('Error', { status: 500 });
}

Advanced Topics

Custom Request Handlers

Add custom routes to the service worker:
app.get('/api/custom/:param', async (c) => {
  const param = c.req.param('param');
  // Custom logic
  return c.json({ result: 'custom' });
});

Cross-Origin Requests

Handle CORS for external resources:
app.use('*', async (c, next) => {
  await next();
  c.res.headers.set('Access-Control-Allow-Origin', '*');
});

Message Passing

Communicate between main thread and service worker:
// Main thread
navigator.serviceWorker.controller.postMessage({
  type: 'CUSTOM_ACTION',
  data: { ... }
});

// Service worker  
self.addEventListener('message', (event) => {
  if (event.data.type === 'CUSTOM_ACTION') {
    // Handle custom action
  }
});

Build docs developers (and LLMs) love