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
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
| Feature | PWA Only | SPA Only | PSPWA |
|---|---|---|---|
| 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.
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.
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
Funkynamespace - 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:
| Pattern | Convention |
|---|---|
| Page IDs | Derived from URL: /trades → trades |
| Entity types | Match API resource names: trade, client |
| API endpoints | Consistent: GET/POST/PUT/DELETE /api/{entity} |
| Lifecycle methods | Always: init(), destroy(), update() |
| Table names | Plural snake_case: trades, trade_actions |
4. Cache-First Navigation
Once you've visited a page, returning to it is instant:
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
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:
- Before state is captured
- Update is executed
- After state is captured
- Audit log is written with diff
- 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
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
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
| Theme | Description |
|---|---|
| Dark (default) | Bloomberg/GitHub dark style — professional, easy on eyes |
| Light | Clean, bright — traditional enterprise feel |
| Even Funkier | Neon 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
| Aspect | Funky | Modern SPA Frameworks |
|---|---|---|
| Bundle size | ~50KB JS | 200KB+ minimum |
| Build step | None required | Webpack/Vite/etc |
| Server rendering | Native (Mojolicious templates) | SSR setup required |
| Learning curve | Vanilla JS patterns | Framework-specific concepts |
| WebSocket | Built-in, automatic | Additional library needed |
| Audit trail | Automatic | Manual 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