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 fallback
  • TenantResolver middleware: 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, AppConfig structs
  • SQLite schema for apps and versions
  • AppRegistry struct 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 AppRouter with 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 TenantResolver middleware

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:

  1. bext deploy ./my-app --app marketing --preview pr-123
  2. Creates a new app version tagged as preview
  3. Registers domain pr-123.preview.example.com → this version
  4. Preview version gets its own isolate and cache
  5. Preview auto-expires after configurable TTL (default: 7 days)
  6. bext apps previews list shows active previews
  7. bext apps previews prune cleans 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 promote and bext rollback commands
  • 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.