Funky

The PSPWA Framework

A comprehensive guide to building modern, secure, real-time web applications with Perl and vanilla JavaScript.

What is a PSPWA?

PSPWA stands for Progressive Single Page Web Application. It's what happens when a PWA and an SPA have a baby, and that baby is raised by a camel who really cares about user experience.

Now, before you roll your eyes and mutter "great, another acronym"—this isn't a new concept we invented to sound clever. The pattern of combining PWA capabilities with SPA architecture has been around for years. Gmail does it. Twitter does it. Most modern web apps worth their salt do it. We just gave it a name that's easier to Google.

The Family Tree

Traditional Website (1990s) ↓ Single Page Application (2010s) — One page, JavaScript handles everything ↓ Progressive Web App (2015+) — Installable, offline, push notifications ↓ PSPWA (Now) — The obvious combination that everyone was already doing
PWA Brings
  • Offline support via Service Workers
  • Install to home screen
  • Push notifications
  • Background sync
SPA Brings
  • No full page reloads
  • Smooth transitions
  • Client-side routing
  • Persistent state

Together They're Unstoppable

FeaturePWA OnlySPA OnlyPSPWA
Works offline
Instant navigation
Installable
No page flicker
Push notifications
Background updates
State persistence

Progressive Caching

Offline support is progressive—pages and API data are cached as you visit them. The more you explore, the more works offline. It's like leaving breadcrumbs, except the breadcrumbs are megabytes of cached HTML and JSON.

First visit to /documentation → Fetched from network → Cached by service worker ↓ Go offline ↓ Visit /documentation again → Served instantly from cache ✓ Visit /some-new-page → Shows friendly offline page (not cached yet)

Pro tip: Wander around while you have WiFi. Your future offline self will thank you.

Fun fact: If you're using this documentation right now in a modern browser, you're experiencing a PSPWA. Try going offline. Try installing it. Try navigating around. It all just... works. That's the point.

Philosophy

Funky is built on a set of core principles that guide every architectural decision:

1. Progressive Enhancement

The application works without JavaScript first. SPA behavior is a layer, not a requirement. If JavaScript fails, users still have a fully functional application. This isn't just about graceful degradation—it's about resilience.

Traditional Request → Server renders HTML → Browser displays ↓ Add JavaScript ↓ SPA intercepts links → AJAX loads content → DOM updates smoothly

2. Modular Independence

Every component is a self-contained unit:

  • No tight coupling — Components communicate through events, not direct references
  • No global pollution — Everything lives under the Funky namespace
  • No dependency chains — Each module can be loaded independently
Bad: Tight coupling
TradesPage.table.reload();
Good: Event-based
Funky.EventBus.emit('data:refresh', { entity: 'trades' });

3. Convention Over Configuration

Predictable patterns reduce cognitive load and accelerate development:

PatternConvention
Page IDsDerived from URL: /tradestrades
Entity typesMatch API resource names: trade, client
API endpointsConsistent: GET/POST/PUT/DELETE /api/{entity}
Lifecycle methodsAlways: init(), destroy(), update()
Table namesPlural snake_case: trades, trade_actions

4. Cache-First Navigation

Once you've visited a page, returning to it is instant:

First Visit: User clicks /trades → Fetch from server (200ms) → Cache HTML + state Repeat Visit: User clicks /trades → Restore from cache (<5ms) → Already done

The cache is automatically invalidated when TTL expires, WebSocket signals an entity change, or user action modifies data.

5. Security by Default

Security isn't an afterthought—it's baked into every layer:

  • CSRF protection on every state-changing request
  • XSS prevention through input sanitization and output escaping
  • Rate limiting to prevent abuse
  • Row-level authorization for multi-tenant data
  • Audit trails for compliance

Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐ │ CLIENT (Browser) │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ FunkyJS │ │ DataTables │ │ Form Modal │ │ Themes │ │ │ │ Core │ │ Component │ │ Component │ │ System │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └─────────────┘ │ │ └────────────────┼────────────────┘ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐│ │ │ Funky.Api (HTTP Client) ││ │ │ CSRF tokens, error handling, retries ││ │ └─────────────────────────────────────────────────────────────────┘│ │ │ │ │ ┌────────────────┴───────────────┐ │ │ ▼ ▼ │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ │ REST API │ │ WebSocket │ │ │ │ /api/* │ │ /ws/realtime │ │ │ └────────┬────────┘ └────────┬────────┘ │ └───────────┼────────────────────────────────┼────────────────────────┘ │ │ ▼ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ SERVER (Mojolicious) │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ │ Controllers │ │ Middleware │ │ WebSocket │ │ │ │ (API + Web) │ │ (Auth, Rate) │ │ Controller │ │ │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ └────────────────────┼────────────────────┘ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐│ │ │ Models (Auditable) ││ │ │ CRUD operations with automatic audit logging ││ │ └─────────────────────────────────────────────────────────────────┘│ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐│ │ │ PostgreSQL + PubSub ││ │ │ Data storage + real-time event broadcasting ││ │ └─────────────────────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────────────┘

The Backend: Perl & Mojolicious

Why Perl?

Perl might seem unconventional in 2025, but it offers unique advantages:

  • Mojolicious — A real-time web framework with built-in WebSocket support
  • Battle-tested — Decades of production-proven stability
  • Expressive — Powerful text processing and regex capabilities
  • Non-blocking I/O — Handles thousands of concurrent WebSocket connections

Model Layer: Auditable by Design

Every model operation is wrapped with audit logging:

package Funky::Model::Trade;
use Funky::Audited;

has 'table_name' => 'trades';

# The 'audited' keyword wraps the method with:
# - Before/after state capture
# - Audit log insertion
# - WebSocket broadcast
audited create => sub {
    my ($self, $data) = @_;
    # ... insert logic ...
    return $trade;
};

When a trade is updated:

  1. Before state is captured
  2. Update is executed
  3. After state is captured
  4. Audit log is written with diff
  5. WebSocket broadcasts { entity: 'trades', action: 'updated', id: 123 }

Helper System

Reusable logic is exposed as helpers:

# Authorization helper
$self->helper(authz => sub {
    my $c = shift;
    return Funky::Helper::Authorization->new(
        pg => $c->pg,
        user => $c->stash('current_user')
    );
});

# Usage in controller
return $c->render(status => 403) unless $c->authz->can_view_client($client_id);

The Frontend: FunkyJS

The Namespace

All JavaScript lives under the Funky global namespace:

window.Funky = {
    // Core
    Api:        /* HTTP client */,
    EventBus:   /* Pub/sub events */,
    Pages:      /* Page lifecycle */,
    SPA:        /* Navigation */,
    Storage:    /* LocalStorage */,
    WebSocket:  /* Real-time connection */,
    
    // Components
    DataTables: /* Smart table wrapper */,
    FormModal:  /* JSON Schema forms */,
    Toast:      /* Notifications */,
    Wizard:     /* Multi-step forms */,
    
    // Utilities
    register:   /* Module registration */,
    has:        /* Check if registered */,
};

Secure Registration

Modules are locked after registration—they cannot be overwritten:

Funky.register = function(name, module) {
    if (Funky.has(name)) {
        console.warn('[Funky] Module already registered:', name);
        return false;
    }
    
    Object.defineProperty(Funky, name, {
        value: Object.freeze(module),
        writable: false,
        configurable: false
    });
    
    return true;
};

This prevents malicious scripts from hijacking core modules.

Component Pattern

Every component follows the same structure:

(function() {
    'use strict';
    
    if (Funky.has('MyComponent')) return;
    
    var MyComponent = {
        // Configuration
        config: { defaultOption: true },
        
        // Initialization
        init: function(options) {
            this.config = Object.assign({}, this.config, options);
            this.bindEvents();
        },
        
        // Cleanup
        destroy: function() {
            this.unbindEvents();
        }
    };
    
    Funky.register('MyComponent', MyComponent);
})();

Security First

CSRF Protection

Every state-changing request requires a CSRF token:

// Funky.Api automatically handles this
Funky.Api.post('/api/trades', tradeData);

// Under the hood:
// 1. Reads token from csrf_token cookie
// 2. Adds X-CSRF-Token header
// 3. Server validates header matches cookie
// 4. Server rotates token after use

Rate Limiting

Endpoints are protected against abuse:

# Sliding window rate limiting
unless ($c->rate_limit->check('api', 100, 60)) {  # 100 requests/minute
    return $c->render(
        status => 429,
        json => { error => 'Rate limit exceeded' }
    );
}

Input Sanitization

All user input is sanitized before processing:

# In controller
my $search = $c->sanitizer->param('search');  # Strips XSS vectors
my $id = $c->sanitizer->param('id');          # Validates format

Row-Level Security

Users only see data they're authorized to access:

# Authorization check
unless ($c->authz->can_view_trade($trade_id)) {
    return $c->render(status => 403, json => { error => 'Forbidden' });
}

Audit Trail

Every data change is logged:

SELECT * FROM audit_logs WHERE table_name = 'trades' AND record_id = 123;

-- Returns:
-- operation: UPDATE
-- old_values: {"status": "pending"}
-- new_values: {"status": "confirmed"}
-- changed_fields: ["status"]
-- user_id: 1
-- ip_address: 192.168.1.1

Real-Time Everything

See It In Action

Watch real-time updates flow through the system:

WebSocket Connection

A persistent connection delivers updates instantly:

// Automatic connection and reconnection
Funky.WebSocket.connect();

// Subscribe to entity updates
Funky.EventBus.on('ws:entity_change', function(data) {
    if (data.entity === 'trades' && data.action === 'created') {
        Funky.Toast.success('New trade created!');
    }
});

Server → Client Flow

Trade Updated (Server) ↓ Audit wrapper captures change ↓ Publish to PostgreSQL LISTEN/NOTIFY ↓ WebSocket controller receives event ↓ Broadcasts to all connected clients ↓ Client receives: { entity: 'trades', action: 'updated', id: 123 } ↓ Page.update() called OR cache invalidated

Smart Updates

Pages declare which entities they care about:

var TradesPage = {
    id: 'trades',
    entities: ['trades', 'trade_allocations'],  // Watch these
    
    update: function(entity, id, action) {
        // Called when WebSocket announces a change
        this.table.ajax.reload(null, false);
    }
};

The Page System

Lifecycle

┌──────────────────────────────────────────────────────────────┐ │ Page Lifecycle │ ├──────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────┐ │ │ │ DORMANT │ ← Page is defined but not active │ │ └────┬────┘ │ │ │ User navigates to page │ │ ▼ │ │ ┌──────────┐ │ │ │ MOUNTING │ ← init() called, state restored │ │ └────┬─────┘ │ │ ▼ │ │ ┌──────────┐ │ │ │ ACTIVE │ ← Page is visible, WebSocket updates work │ │ └────┬─────┘ │ │ │ User navigates away │ │ ▼ │ │ ┌───────────┐ │ │ │ UNMOUNTING│ ← destroy() called, state saved │ │ └─────┬─────┘ │ │ ▼ │ │ ┌──────────┐ │ │ │ CACHED │ ← HTML + state preserved in memory │ │ └──────────┘ │ │ │ └──────────────────────────────────────────────────────────────┘

Example Page

var ClientsPage = {
    id: 'clients',
    entities: ['clients', 'client_relationships'],
    
    init: function(state) {
        // Called when page becomes visible
        this.initDataTable();
        this.bindEvents();
        
        // Restore scroll position from cached state
        if (state && state.scrollY) {
            window.scrollTo(0, state.scrollY);
        }
    },
    
    destroy: function() {
        // Called before navigating away
        if (this.table) {
            this.table.destroy();
        }
        
        // Return state to be cached
        return { scrollY: window.scrollY };
    },
    
    update: function(entity, id, action) {
        // Called when WebSocket signals change
        if (this.table) {
            this.table.ajax.reload(null, false);
        }
    }
};

Funky.Pages.register(ClientsPage);

Push Notifications

Browser Push with VAPID

Funky supports native browser push notifications:

// Subscribe the user
Funky.Push.subscribe().then(function(subscription) {
    console.log('Push subscription active');
});

Server-Side Sending

# Send to specific user
$c->push_notification_model->send_to_user($user_id, {
    title => 'Trade Confirmed',
    body  => 'Trade #123 has been confirmed',
    icon  => '/assets/icons/trade.png',
    data  => { trade_id => 123 }
});

Service Worker

Notifications work even when the app is closed:

// In service worker
self.addEventListener('push', function(event) {
    const data = event.data.json();
    event.waitUntil(
        self.registration.showNotification(data.title, {
            body: data.body,
            icon: data.icon,
            data: data.data
        })
    );
});

// Click handler
self.addEventListener('notificationclick', function(event) {
    event.notification.close();
    event.waitUntil(
        clients.openWindow('/trades/' + event.notification.data.trade_id)
    );
});

Theming System

Three Built-in Themes

ThemeDescription
Dark (default)Bloomberg/GitHub dark style — professional, easy on eyes
LightClean, bright — traditional enterprise feel
Even FunkierNeon cyberpunk — because why not?

CSS Variables

All theming uses CSS custom properties:

:root {
    --pro-bg-primary: #0d1117;
    --pro-bg-secondary: #161b22;
    --pro-text-primary: #e6edf3;
    --pro-accent-primary: #2f81f7;
    /* ... 50+ variables ... */
}

[data-theme="light"] {
    --pro-bg-primary: #ffffff;
    --pro-text-primary: #1f2328;
}

[data-theme="even-funkyer"] {
    --pro-bg-primary: #0a0015;
    --pro-accent-primary: #ff00ff;
    --pro-accent-secondary: #00ffff;
}

User Customization

Funky.Storage.set('user_preferences', {
    theme: 'dark',
    font_scale: 1.1,
    accent_color: '#ff6b35',
    compact_mode: true
});

Why Funky?

Compared to React/Vue/Angular

AspectFunkyModern SPA Frameworks
Bundle size~50KB JS200KB+ minimum
Build stepNone requiredWebpack/Vite/etc
Server renderingNative (Mojolicious templates)SSR setup required
Learning curveVanilla JS patternsFramework-specific concepts
WebSocketBuilt-in, automaticAdditional library needed
Audit trailAutomaticManual implementation

When to Use Funky

Good fit: Enterprise/internal applications, data-heavy dashboards, real-time collaborative tools, applications requiring audit compliance, teams comfortable with Perl

Consider alternatives: Public-facing marketing sites (static generators), mobile-first applications (React Native, Flutter), micro-frontends architecture

"Write less code. Ship more features. Sleep better at night."

Funky eliminates boilerplate by providing sensible defaults while remaining flexible enough for complex requirements. Security, real-time updates, and audit logging come free—you just build your features.