03 — Multi-App Routing
The multi-app system extends bext's existing multi-tenant routing to support multiple independent applications behind a single bext instance.
Current State
bext already has multi-tenant routing:
TenantCache: hostname → (tenantId, siteId) lookup with DashMap + wildcard fallbackTenantResolvermiddleware: extracts tenant from request, attaches to context- ISR cache keys include tenantId:
t:{tenantId}:s:{siteId}:l:{locale}:b:{isBot}:p:{pathname} - Rate limiting is per-IP
- JSC render pool is shared across all tenants
Target State
Multi-app routing where each app is an isolated unit with its own:
- Domain(s) / hostname patterns
- Source directory and SSR bundle
- JS isolate(s) with memory limits
- ISR cache namespace
- Rate limit configuration
- Auth configuration
- Plugin configuration
- Deploy history (versioned builds)
Data Model
App Registry
/// An application registered with the platform.
pub struct App {
pub id: String, // "marketing", "dashboard", etc.
pub name: String, // Human-readable name
pub domains: Vec, // Hostname patterns
pub source: PathBuf, // Source directory
pub runtime: RuntimeKind, // SSR, JS, Static
pub current_version: Option,
pub previous_version: Option, // For rollback
pub config: AppConfig,
pub status: AppStatus,
pub created_at: DateTime,
pub updated_at: DateTime,
}
pub enum RuntimeKind {
Ssr, // Full SSR with JSC isolate
Js, // JS handler (API server, no SSR)
Static, // Static file serving only
Hybrid, // SSR + proxy to external API
}
pub enum AppStatus {
Running,
Stopped,
Deploying,
Failed { error: String },
Draining, // Old version serving while new deploys
}
pub struct AppVersion {
pub version: String, // Semver or git SHA
pub bundle_path: PathBuf, // Path to SSR bundle
pub static_dir: Option,
pub deployed_at: DateTime,
pub deployed_by: Option,
pub routes: Vec,
}
pub struct AppConfig {
pub cache: CacheConfig,
pub rate_limit: RateLimitConfig,
pub auth: AuthConfig,
pub transforms: TransformProfile,
pub isolate: IsolateConfig,
pub plugins: Vec,
}
/// Domain pattern supporting exact match and wildcards.
pub enum DomainPattern {
Exact(String), // "example.com"
Wildcard(String), // "*.example.com"
Regex(Regex), // Complex patterns
}
Storage
App registry stored in SQLite (alongside the flow engine):
CREATE TABLE apps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
domains TEXT NOT NULL, -- JSON array of domain patterns
source TEXT NOT NULL,
runtime TEXT NOT NULL,
config TEXT NOT NULL, -- JSON AppConfig
status TEXT NOT NULL DEFAULT 'stopped',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE app_versions (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id),
version TEXT NOT NULL,
bundle_path TEXT,
static_dir TEXT,
routes TEXT, -- JSON scanned routes
deployed_at TEXT NOT NULL,
deployed_by TEXT,
is_current BOOLEAN DEFAULT FALSE,
UNIQUE(app_id, version)
);
CREATE INDEX idx_app_versions_current ON app_versions(app_id, is_current) WHERE is_current;
Routing Architecture
Request Flow
HTTP request (hostname: app.example.com)
│
├── AppRouter::resolve(hostname)
│ └── DashMap<String, Arc> (hostname → app, same pattern as TenantCache)
│ └── Wildcard fallback for *.example.com patterns
│ └── Returns AppContext { app_id, app_config, ... }
│
├── Per-app middleware chain:
│ ├── Rate limiting (app-specific limits)
│ ├── Auth (app-specific config)
│ ├── Plugin middleware (app-specific plugins)
│ └── CORS (app-specific origins)
│
├── Request handling:
│ ├── Static? → serve from app's static_dir
│ ├── ISR hit? → serve from app's cache namespace
│ ├── SSR? → render in app's isolate
│ └── JS? → evaluate in app's isolate
│
└── Response (with per-app headers, cache tags, etc.)
Cache Namespace Isolation
ISR cache keys are already tenant-scoped. For multi-app, we prefix with app_id:
Current: t:{tenantId}:s:{siteId}:l:{locale}:b:{isBot}:p:{pathname}
Platform: a:{appId}:l:{locale}:b:{isBot}:p:{pathname}
This allows per-app cache purging without affecting other apps.
For apps that are themselves multi-tenant (like Company Manager), the key includes both:
a:{appId}:t:{tenantId}:s:{siteId}:l:{locale}:b:{isBot}:p:{pathname}
Implementation Tasks
MA-1: App Registry
Persistent app registry with CRUD operations.
File: bext-core/src/platform/registry.rs
Tasks:
- Define
App,AppVersion,AppConfigstructs - SQLite schema for apps and versions
-
AppRegistrystruct with CRUD methods - Domain pattern matching (exact, wildcard, regex)
- Validation: no overlapping domains between apps
- Version management (create, set current, get previous for rollback)
- Tests for registry operations
MA-2: App Router
Route incoming requests to the correct app based on hostname.
File: bext-core/src/platform/router.rs
pub struct AppRouter {
/// Fast path: exact hostname → app
exact: DashMap<String, Arc>,
/// Slow path: wildcard patterns checked in order
wildcards: Vec<(DomainPattern, Arc)>,
/// Default app (optional, for catch-all)
default: Option<Arc>,
}
impl AppRouter {
pub fn resolve(&self, hostname: &str) -> Option<Arc>;
pub fn reload(&self, apps: Vec);
pub fn add(&self, app: App);
pub fn remove(&self, app_id: &str);
}
Tasks:
- Implement
AppRouterwith DashMap exact + wildcard fallback - Hot-reload: add/remove apps without restart
- Default app for unmatched hostnames (configurable)
- Conflict detection: warn on overlapping domain patterns
- Port 80/443 routing (TLS-based SNI selection, future)
- Metrics: per-app request counts
- Integration with existing
TenantResolvermiddleware
MA-3: Per-App Middleware Chain
Each app has its own middleware configuration.
Current middleware stack (shared):
CORS → Rate Limit → Auth → User Middleware → Tenant → Tracing
Target: per-app middleware:
Global: CORS → Tracing
Per-app: Rate Limit(app) → Auth(app) → Plugins(app) → Tenant(app)
Tasks:
- Refactor middleware to accept per-app config
- Rate limiter: per-app RPM limits (currently per-IP only)
- Auth: per-app JWT secrets and bypass rules
- Plugin middleware: per-app plugin registry
- Tenant resolution: scoped by app (some apps are multi-tenant, others aren't)
- Middleware ordering configurable per app
- Custom headers per app (X-App-Id, etc.)
MA-4: Preview Deployments
Each git branch gets a preview URL: pr-123.preview.example.com
How it works:
bext deploy ./my-app --app marketing --preview pr-123- Creates a new app version tagged as preview
- Registers domain
pr-123.preview.example.com→ this version - Preview version gets its own isolate and cache
- Preview auto-expires after configurable TTL (default: 7 days)
bext apps previews listshows active previewsbext apps previews prunecleans up expired
Tasks:
- Add
--preview <name>flag to deploy command - Preview domain template in platform config:
{name}.preview.{domain} - Preview auto-expiry with configurable TTL
- Cleanup: evict preview isolate and cache on expiry
- List/prune preview commands
- Prevent previews from using production databases (env isolation)
- Share static assets between preview and production (dedup)
MA-5: Traffic Splitting (Canary Deploys)
Route a percentage of traffic to a new version for safe rollouts.
Current: Edge rewrites plugin already does A/B testing with weighted sticky cookies.
Extension: Use the same mechanism for canary deploys:
bext deploy ./my-app --app marketing --canary 5%
# 5% of traffic goes to new version, 95% to current
# Cookie-based sticky assignment (user stays on same version)
bext promote marketing # Increase to 100%
bext promote marketing --to 25% # Increase to 25%
bext rollback marketing # Back to 0% (previous version only)
Tasks:
- Extend edge rewrites plugin for version-based routing
- Sticky cookie assignment (user stays on same version per session)
-
bext deploy --canary <percent>flag -
bext promoteandbext rollbackcommands - Metrics: per-version error rates, latency
- Auto-promote: if error rate < threshold after N minutes, auto-promote
- Auto-rollback: if error rate > threshold, auto-rollback
Configuration
platform.toml
[platform]
listen = "0.0.0.0:3000"
data_dir = "./data" # SQLite, plugin storage, build artifacts
preview_ttl = "7d" # Auto-expire previews
max_apps = 50 # Limit for resource planning
max_isolates = 100 # Total across all apps
# TLS (optional)
# tls_cert = "/path/to/cert.pem"
# tls_key = "/path/to/key.pem"
# Default app (for unmatched hostnames)
# default_app = "marketing"
[apps.marketing]
source = "./apps/marketing"
domains = ["example.com", "www.example.com"]
runtime = "ssr"
[apps.marketing.cache]
default_ttl = "1h"
max_entries = 5000
[apps.marketing.rate_limit]
rpm = 600
[apps.marketing.isolate]
workers = 4
memory_limit = "128MB"
[apps.dashboard]
source = "./apps/dashboard"
domains = ["app.example.com"]
runtime = "ssr"
auth.required = true
auth.jwt_secret_env = "DASHBOARD_JWT_SECRET"
[apps.api]
source = "./apps/api"
domains = ["api.example.com"]
runtime = "js"
rate_limit.rpm = 1000
[apps.docs]
source = "./apps/docs"
domains = ["docs.example.com"]
runtime = "static"
Migration from Single-App
For users who just want to serve one app (the current use case), everything works the same:
# Before (still works)
bext run ./my-app
# Equivalent to
bext serve --config <auto-generated platform.toml with single app>
The bext run command is syntactic sugar for a single-app platform.