Skip to content

eXeLearning Architecture

This document describes the technical architecture of eXeLearning, an educational content authoring tool built with modern web technologies.

1. Overview

Design Philosophy

eXeLearning follows a browser-first architecture where:

  • Client is the source of truth: The browser holds the authoritative state in IndexedDB and in-memory Yjs documents
  • Server is for synchronization: The server acts as a stateless relay, coordinating between clients without storing document state
  • Explicit saves only: Content is persisted to the server only when the user explicitly saves
  • Offline-capable: Users can work without network connectivity; sync happens when online

Key Principles

  1. Zero server memory overhead: No Y.Doc stored on server per document
  2. P2P asset coordination: Assets shared between collaborating clients via server coordination
  3. Content-addressable assets: Same file content = same ID across all users
  4. Dependency injection: All services use DI pattern for testability

Runtime Modes (Server, Static, Embedded)

eXeLearning supports two primary runtime modes and one optional embedding mode:

Mode What runs Persistence Collaboration Typical use
Server (online) Bun + Elysia API + WebSocket Server DB + client IndexedDB Yes Multi-user, hosted installs
Static (offline) Pure static assets (no API) Client IndexedDB + file save/export No Desktop installers, static hosting
Embedded (iframe) Server or Static in iframe Delegated to host via postMessage Depends on mode LMS, CMS, plugin embeds

Static mode is built from the online app by removing database and API dependencies to stay stateless. The build bundles required catalog data (iDevices, themes, translations, parameters) into dist/static and runs fully in the browser. It is generated with:

make build-static

This is the same runtime used by the current offline (installable) eXeLearning desktop app. It improves simplicity and performance by avoiding any server persistence.

Embedding Example (Static or Server)

When eXeLearning runs inside an iframe, it can coordinate file operations with the host page via postMessage. The app announces readiness with EXELEARNING_READY and can request open/save operations. A minimal host integration:

<iframe
  id="exe"
  src="https://your-host/exelearning/"
  style="width: 100%; height: 100vh; border: 0;"
></iframe>

<script>
  const iframe = document.getElementById('exe');

  function postToExe(type, data = {}) {
    const requestId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
    iframe.contentWindow.postMessage({ type, requestId, ...data }, '*');
    return requestId;
  }

  window.addEventListener('message', async (event) => {
    const { type, requestId, bytes } = event.data || {};
    if (!type) return;

    if (type === 'EXELEARNING_READY') {
      // Optional: set trusted origins or request info
      postToExe('GET_PROJECT_INFO');
    }

    if (type === 'EXELEARNING_SAVE_REQUEST') {
      // Host handles file persistence
      // Example: upload bytes to your backend, then respond
      event.source.postMessage({ type: 'EXELEARNING_SAVE_RESPONSE', requestId, path: 'project.elpx' }, event.origin);
    }

    if (type === 'EXELEARNING_OPEN_REQUEST') {
      // Host provides bytes for a .elpx file (from disk or backend)
      const fileBytes = new Uint8Array(); // replace with real data
      event.source.postMessage({ type: 'EXELEARNING_OPEN_RESPONSE', requestId, bytes: fileBytes }, event.origin);
    }
  });
</script>

For production, restrict origins and validate all messages (see EmbeddedFileSystem and EmbeddingBridge for supported message types and security checks).

2. Technology Stack

Component Technology Purpose
Runtime Bun Fast JavaScript runtime and package manager
Framework Elysia Type-safe web framework
ORM Kysely Type-safe SQL query builder
Database SQLite / PostgreSQL / MySQL Multi-database support
Real-time WebSocket + Yjs Collaborative editing
Frontend Vanilla JavaScript No framework dependencies
Storage IndexedDB Client-side asset and document storage
Templates Nunjucks Server-side HTML rendering
Desktop Electron Cross-platform desktop application

3. System Architecture

graph TB
    subgraph "Browser (Client)"
        UI[User Interface]
        YDoc["Y.Doc (in-memory)"]
        IDB[(IndexedDB)]
        AM[AssetManager]
        WSH[WebSocket Handler]

        UI --> YDoc
        YDoc <--> IDB
        AM <--> IDB
        UI --> AM
        WSH --> YDoc
        WSH --> AM
    end

    subgraph "Server (Elysia + Bun)"
        Routes[REST Routes]
        WS[WebSocket Relay]
        RM[Room Manager]
        AC[Asset Coordinator]
        SM[Session Manager]
        Kysely[Kysely ORM]

        Routes --> SM
        Routes --> Kysely
        WS --> RM
        WS --> AC
        AC --> Kysely
    end

    subgraph "Storage"
        DB[(Database)]
        FS[File System]
    end

    Browser <-->|"REST API"| Routes
    Browser <-->|"WebSocket (Yjs Binary)"| WS
    Kysely --> DB
    SM --> FS

Request Flow

  1. Page Load: Browser fetches HTML from REST API, renders workarea
  2. Document Open: YjsDocumentManager initializes Y.Doc, syncs from IndexedDB
  3. WebSocket Connect: y-websocket provider connects for real-time sync
  4. Editing: Changes update Y.Doc → replicated to peers via WebSocket
  5. Save: User clicks save → REST API persists Yjs state to database

4. WebSocket & Yjs Collaboration

4.1 Stateless Relay Architecture

The WebSocket server does NOT maintain Y.Doc state:

┌─────────────────────────────────────────────────────────────┐
│                    Traditional Architecture                  │
│  Client A ──► Server Y.Doc ◄── Client B                     │
│              (memory overhead)                               │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                eXeLearning Stateless Relay                   │
│  Client A ──────► Relay ◄────── Client B                    │
│  (Y.Doc)     (zero memory)      (Y.Doc)                     │
└─────────────────────────────────────────────────────────────┘

Benefits: - No memory per document on server - Scales to many concurrent documents - Client IndexedDB provides offline persistence

4.2 Connection Flow

sequenceDiagram
    participant Client
    participant Server
    participant Room

    Client->>Server: WebSocket connect<br/>/yjs/project-{uuid}?token=JWT
    Server->>Server: Validate JWT token
    Server->>Server: Check project access
    Server->>Room: getOrCreateRoom()
    Room->>Room: Add connection
    Server->>Client: Connection established

    Client->>Server: Yjs sync step 1 (binary)
    Server->>Room: relayMessage() to other clients

    Note over Client,Server: Continuous sync during editing

    Client->>Server: Close connection
    Server->>Room: removeConnection()
    Room->>Room: Schedule cleanup (30s delay)

4.3 Message Protocol

The server distinguishes between two message types:

Type Detection Handler
Yjs sync Binary, starts with bytes 0-2 Relayed to room peers
Asset protocol Binary with 0xFF prefix + JSON Handled by AssetCoordinator
// Message discrimination in message-parser.ts
if (message[0] === 0xFF) {
    // Asset protocol message
    const json = message.subarray(1);
    return { kind: 'asset', message: JSON.parse(json) };
}
// Default: Yjs binary message
return { kind: 'yjs', data: message };

4.4 Room Management

Rooms are created per document and track connected clients:

interface Room {
    name: string;                    // "project-{uuid}"
    conns: Set<WebSocket>;           // Connected clients
    projectUuid: string;
    cleanupController?: AbortController;  // For cancellable cleanup
}

Lifecycle: 1. Create: First client connects → room created 2. Active: Messages relayed between clients 3. Cleanup Scheduled: Last client disconnects → 30s timer starts 4. Cleanup Cancelled: Client reconnects before timer → cancel cleanup 5. Destroyed: Timer fires → room deleted, assets cleaned up

4.5 Heartbeat & Keep-Alive

WebSocket connections use ping/pong for health checking:

Environment Ping Interval Cleanup Delay
Desktop 60s 5s
Server 30s 30s

The shorter server interval avoids proxy timeout issues (typically 60s).

5. Asset Management System

5.1 Browser-First Storage

Assets are stored primarily in IndexedDB on the client:

// IndexedDB schema (exelearning-assets-v2)
{
    id: string,           // Content-addressable UUID
    projectId: string,    // Project reference
    blob: Blob,           // Actual file data
    mime: string,         // MIME type
    hash: string,         // SHA-256 hex
    size: number,
    uploaded: boolean,    // Server sync status
    filename: string
}

5.2 Content-Addressable URLs

Assets use deterministic IDs based on content hash:

asset://8f3e9c2a-1b4d-4f7e-9a3c-2d1e5f7a9b3c
asset://8f3e9c2a-1b4d-4f7e-9a3c-2d1e5f7a9b3c/photo.jpg

Benefits: - Same file = same ID (deduplication) - Idempotent uploads - Efficient P2P sharing

5.3 P2P Asset Coordination

When multiple users collaborate, assets are shared via server coordination:

sequenceDiagram
    participant Alice as Alice (has asset)
    participant Server as Server
    participant Bob as Bob (needs asset)

    Alice->>Server: awareness-update<br/>["asset-1", "asset-2"]
    Note over Server: Track: Alice has asset-1, asset-2

    Bob->>Server: request-asset<br/>{assetId: "asset-1", priority: HIGH}
    Server->>Server: Check DB (not found)
    Server->>Server: Check peers (Alice has it)
    Server->>Alice: upload-request<br/>{assetId: "asset-1"}

    Alice->>Server: Upload asset-1 via REST
    Server->>Bob: asset-ready<br/>{assetId: "asset-1", url: "/api/..."}

    Bob->>Server: Download asset-1
    Bob->>Bob: Store in IndexedDB

5.4 Priority Queue System

Asset uploads are prioritized to ensure responsive UI:

Priority Value Use Case
CRITICAL 100 Asset needed for current render
HIGH 75 Asset on current page
MEDIUM 50 Asset on nearby pages
LOW 25 Background prefetch
IDLE 0 Normal save upload

Preemption rules: - Max 3 concurrent uploads per project - Higher priority can preempt if: priority diff ≥ 50 AND upload running ≥ 2s

5.5 Save Flow

flowchart LR
    subgraph Browser
        A[User clicks Save] --> B[Get pending assets]
        B --> C[Create priority batches]
        C --> D[Upload batches in parallel]
        D --> E[Save Yjs state]
        E --> F[Mark assets uploaded]
    end

    subgraph Server
        D --> G[Store assets on disk]
        E --> H[Store Yjs snapshot in DB]
    end

Batch configuration: - Critical: 1 asset per batch (fastest) - High priority: max 5 files or 5MB per batch - Normal: max 15 files or 10MB per batch - Max concurrent batches: 5

6. Session Management

6.1 Session Lifecycle

Each opened project gets a unique session:

interface ProjectSession {
    sessionId: string;        // UUID
    projectId: number;        // Database ID
    projectUuid: string;      // Project UUID
    sessionPath: string;      // tmp/{sessionId}/
    contentPath: string;      // Path to extracted content
    createdAt: Date;
    modifiedAt: Date;
}

Sessions are stored in-memory (Map<sessionId, ProjectSession>).

6.2 File System Structure

FILES_DIR/
├── tmp/{sessionId}/          # Temporary session directory
│   ├── content.xml           # Extracted ELP content
│   ├── assets/               # Uploaded assets
│   └── dist/                 # Export output
├── perm/odes/{projectId}/    # Permanent storage
│   └── assets/
└── data/
    └── chunks/{projectId}/   # Chunked upload temp storage

7. Export System

7.1 Supported Formats

Format Description Use Case
HTML5 Self-contained website Web hosting
SCORM 1.2 LMS package Moodle, Blackboard (legacy)
SCORM 2004 LMS package Modern LMS
EPUB3 E-book format E-readers
IMS CP Content package Standards compliance

7.2 Export Strategy Pattern

interface ExportStrategy {
    export(session: ProjectSession, options: ExportOptions): Promise<string>;
    getManifest(): Promise<string>;
    copyAssets(): Promise<void>;
}

// Usage
const exporter = ExportFactory.create('scorm12');
const zipPath = await exporter.export(session, options);

8. Preview System (Service Worker Architecture)

The workarea preview uses a Service Worker to serve exported HTML files directly, ensuring the preview exactly matches the exported output.

8.1 Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                      WORKAREA                                │
├─────────────────────────────────────────────────────────────┤
│  Yjs Bridge                    PreviewPanelManager          │
│      │                               │                       │
│      │ onStructureChange()           │                       │
│      └──────────────────────────────►│                       │
│                                      │                       │
│                            1. Generate export                │
│                               (Html5Exporter)                │
│                                      │                       │
│                            2. Send to SW                     │
│                               (SET_CONTENT)                  │
│                                      │                       │
│                            3. Load iframe                    │
│                               /viewer/index.html             │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                   SERVICE WORKER                             │
├─────────────────────────────────────────────────────────────┤
│  contentFiles: Map<path, ArrayBuffer>                        │
│                                                              │
│  fetch('/viewer/*') → handleViewerRequest()                  │
│      ├── Look up path in contentFiles                        │
│      ├── Return Response with correct MIME type              │
│      └── Inject external link handler for HTML               │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                   PREVIEW IFRAME                             │
├─────────────────────────────────────────────────────────────┤
│  <iframe src="/viewer/index.html">                          │
│      │                                                       │
│      ├── CSS: /viewer/theme/style.css                       │
│      ├── JS:  /viewer/libs/common.js                        │
│      ├── IMG: /viewer/content/resources/image.jpg           │
│      └── All served by SW from memory!                       │
└─────────────────────────────────────────────────────────────┘

8.2 How It Works

  1. Generate Export: When preview is requested, Html5Exporter.generateForPreview() generates all export files in memory as Map<string, Uint8Array | string>

  2. Send to Service Worker: Files are sent to the SW via postMessage({ type: 'SET_CONTENT', files })

  3. Load Preview: The iframe loads /viewer/index.html - all requests intercepted by SW

  4. Serve Files: SW serves files from its in-memory cache with correct MIME types

8.3 Key Components

Component File Purpose
Service Worker public/app/preview-sw.js Intercepts /viewer/* requests
Preview Panel public/app/workarea/interface/elements/previewPanel.js UI and orchestration
Export Generator src/shared/export/exporters/Html5Exporter.ts Generates export files
Browser API src/shared/export/browser/index.ts Browser-side export functions

8.4 Benefits

  • Exact match: Preview IS the exported HTML (same code path, same output)
  • No workarounds: No blob:// URL complexity for assets
  • Native PDF viewer: PDFs work without PDF.js workaround (not in nested blob context)
  • External links: Handled correctly (SW injects click handler)
  • Multi-page navigation: Works exactly like the real export

8.5 Service Worker Protocol

// Messages sent to Service Worker
{ type: 'SET_CONTENT', files: Map<path, ArrayBuffer> }  // Set preview content
{ type: 'CLEAR_CONTENT' }                                // Clear preview cache
{ type: 'CLAIM_CLIENTS' }                                // Activate SW for page

9. Database Architecture

9.1 Multi-Database Support

Kysely ORM with dialect adapters:

// src/db/dialect.ts
function createDialect(): Dialect {
    switch (process.env.DB_DRIVER) {
        case 'pdo_sqlite':
            return new BunWorkerDialect({ /* SQLite config */ });
        case 'pdo_pgsql':
            return new PostgresDialect({ /* PostgreSQL config */ });
        case 'pdo_mysql':
            return new MysqlDialect({ /* MySQL config */ });
    }
}

9.2 Query Pattern with Dependency Injection

All queries accept db as first parameter for testability:

// src/db/queries/projects.ts
export async function findProjectByUuid(
    db: Kysely<Database>,
    uuid: string
): Promise<Project | undefined> {
    return db.selectFrom('projects')
        .where('uuid', '=', uuid)
        .selectAll()
        .executeTakeFirst();
}

Testing with DI:

// In tests
configure({
    queries: { findProjectByUuid: mockFindProjectByUuid }
});

afterEach(() => resetDependencies());

10. Configuration

10.1 Environment Variables

Key configuration in .env:

# Server
APP_PORT=8080
APP_SECRET=your-jwt-secret

# Database
DB_DRIVER=pdo_sqlite          # pdo_sqlite | pdo_pgsql | pdo_mysql
DB_PATH=/mnt/data/exelearning.db

# File Storage
FILES_DIR=/mnt/data/

# Optional
BASE_PATH=/exelearning        # URL prefix for subdirectory install
APP_AUTH_METHODS=password     # password,cas,openid,guest

10.2 Desktop vs Server Configuration

Setting Desktop (Electron) Server
Ping interval 60s 30s
Cleanup delay 5s 30s
Compact threshold 100 updates 50 updates
WebSocket timeout 70s 40s

11. Key File Locations

Backend (src/)

Path Purpose
src/index.ts Elysia entry point
src/routes/ REST API routes
src/services/ Business logic
src/db/client.ts Kysely instance
src/db/queries/ Database queries
src/websocket/yjs-websocket.ts WebSocket handler
src/websocket/room-manager.ts Room lifecycle
src/websocket/asset-coordinator.ts P2P coordination
src/shared/export/exporters/Html5Exporter.ts HTML5 export (also used for preview)
src/shared/export/browser/index.ts Browser-side export API

Frontend (public/app/)

Path Purpose
public/app/yjs/YjsDocumentManager.js Y.Doc management
public/app/yjs/AssetManager.js IndexedDB storage
public/app/yjs/AssetWebSocketHandler.js Asset protocol
public/app/yjs/SaveManager.js Save orchestration
public/app/yjs/YjsProjectBridge.js Coordination hub
public/app/preview-sw.js Preview Service Worker
public/app/workarea/interface/elements/previewPanel.js Preview panel UI

Configuration

Path Purpose
.env Environment configuration
tsconfig.json TypeScript config
bunfig.toml Bun configuration
Makefile Build commands

12. Testing

Test Strategy

  • Unit tests: Next to source files (*.spec.ts)
  • Integration tests: test/integration/
  • E2E tests: Playwright
  • Coverage target: 90%

Running Tests

# Run all unit tests
make test-unit

# Run with coverage
make test-coverage

# Run specific test
DB_PATH=:memory: ELYSIA_FILES_DIR=/tmp/test bun test src/path/to/file.spec.ts

DI Pattern for Testing

Never use mock.module(). Use dependency injection:

// Configure mocks
configure({
    db: testDb,
    queries: { findById: mockFindById }
});

// Reset after test
afterEach(() => resetDependencies());

13. Security

Authentication

  • JWT tokens for API and WebSocket authentication
  • Token passed in query string for WebSocket (?token=JWT)
  • Multiple auth methods: password, CAS, OpenID Connect, guest

Authorization

  • Project access checked before WebSocket connection
  • User ID tracked per connection for audit
  • Path traversal prevention with isPathSafe()

WebSocket Security

// Connection validation
ws.on('open', async () => {
    const token = url.searchParams.get('token');
    const decoded = await verifyToken(token);
    if (!decoded) {
        ws.close(4001, 'Invalid token');
        return;
    }
    const hasAccess = await checkProjectAccess(db, decoded.userId, projectUuid);
    if (!hasAccess) {
        ws.close(4003, 'Access denied');
        return;
    }
});

14. Theme Architecture

14.1 Theme Types

eXeLearning supports three types of themes:

Type Source Storage Served By
Base Built-in with eXeLearning Server /perm/themes/base/ Server
Site Admin-installed for all users Server /perm/themes/site/ Server
User Imported by user or from .elpx Client IndexedDB + Yjs Never server

14.2 Server Themes (Base & Site)

Base themes are included with eXeLearning and synchronized at startup: - Located in /public/files/perm/themes/base/ - Cannot be modified by users - Served directly by the server

Site themes are installed by administrators for all users: - Located in /perm/themes/site/ - Admin can activate/deactivate themes - Admin can set a default theme for new projects - Served directly by the server

14.3 User Themes (Client-Side Only)

Important: User themes are NEVER stored or served by the server.

User themes are stored entirely on the client side:

┌─────────────────────────────────────────────────────────────────────┐
│                     USER THEME STORAGE                              │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  IndexedDB (per-user isolation)                                     │
│  └── user-themes store: key = "userId:themeName"                    │
│      └── Each user's themes isolated by userId prefix               │
│      └── User "alice" cannot see user "bob"'s themes                │
│                                                                     │
│  Yjs themeFiles (project document)                                  │
│  └── Currently selected user theme (for collaboration/export)       │
│                                                                     │
│  .elpx export                                                       │
│  └── Embedded theme files (for portability)                         │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

14.4 User Theme Flow

1. IMPORT THEME
   User uploads ZIP → Stored in IndexedDB (local storage)

2. SELECT THEME
   User selects theme → Copied to Yjs themeFiles
   (enables collaboration and export)

3. CHANGE TO ANOTHER THEME
   User selects different theme → Removed from Yjs
   (but remains in IndexedDB for future use)

4. EXPORT PROJECT (.elpx)
   If user theme selected → Embedded in ZIP

5. OPEN PROJECT WITH EMBEDDED THEME
   Another user opens .elpx → Theme extracted to their IndexedDB
   (if ONLINE_THEMES_INSTALL is enabled)

14.5 Admin Configuration

# Allow users to import/install styles
ONLINE_THEMES_INSTALL=1    # 1 = enabled (default), 0 = disabled

When disabled (ONLINE_THEMES_INSTALL=0): - Users cannot import external themes via the interface - Users cannot load custom embbeded themes in .elpx files

14.6 Why User Themes Are Client-Side

This design follows the same pattern as other user-specific data (like favorite iDevices):

  1. Per-user storage: Each user's themes are private to them
  2. No server storage: Themes don't consume server disk space
  3. Collaboration via Yjs: Selected theme is shared with collaborators in real-time
  4. Portability: Themes embedded in .elpx can be opened anywhere
  5. Offline capability: Themes work without server connectivity

Further Reading