Skip to content

Documentation

Server Hooks & Middleware

Enterprise-grade middleware architecture with unified metrics and production-ready optimizations.

9/30/2026
52 min read Edit on GitHub
On this page

SveltyCMS employs an enterprise-grade, streamlined middleware pipeline using SvelteKit Server Hooks. This architecture emphasizes security, performance, and observability with unified metrics collection and automated threat detection.


Design Philosophy

The middleware architecture is built on core enterprise principles:

  1. Sequential & Predictable: Hooks run in a defined order for every request, making the system easy to understand, debug, and scale.

  2. Centralized State-Guarding: The handleSystemState hook acts as the authoritative gatekeeper, ensuring no requests are processed unless the system is operational.

  3. Unified Metrics & Monitoring: All hooks integrate with the MetricsService for comprehensive performance and security monitoring.

  4. Production-Optimized: WeakRef-based memory management, non-blocking streaming, and distributed rate limiting for clustered deployments. O(1) request lane classification routes health/static/turbo traffic before the full sequence.

  5. Framework-Native: Leverages SvelteKit’s built-in CSP and optimizes around framework capabilities.


πŸ›‘οΈ Global Security Guard

SveltyCMS implements a Top-Level Security Guard in hooks.server.ts that wraps the entire middleware sequence. This guarantees that every single responseβ€”including 302 redirects, 404 Not Found, and 500 Server Errorsβ€”carries the full suite of enterprise security headers (HSTS, CSP, X-Frame-Options).

Both the trace-enabled and production fast paths use a try/catch wrapper that calls applyAllSecurityHeaders() on middleware errors before returning, so security headers are never dropped when hooks throw.


🚦 Request Lane Router (O(1) pre-pipeline)

Before the READY/SETUP sequence runs, export const handle classifies every request with classifyRequest() (src/hooks/handle-request-classifier.ts) and stamps the response with x-svelty-lane.

Lane When Behavior
FAST_STATIC /favicon.ico, /robots.txt, /_app/*, /static/* Skip full stack; long-cache headers
HEALTH /health, /api/system/health Immediate JSON health (no auth / full pipeline)
HYPER_TURBO Authenticated GET/HEAD on cacheable API prefixes + session cookie Eligible for handleTurboGet TURBO-HIT via user-scoped responseCache
API_READ Other GET API Full security + auth path
API_WRITE POST/PUT/PATCH/DELETE Full CSRF + RBAC + mutation pipeline
APP_SSR Admin pages Full SSR pipeline
BOOTSTRAP /setup, /login, /auth Setup/auth shell
FILES /files/*, /media/* File server path
PUBLIC_SITE Non-admin public pages Public SSR

Session cookie for turbo detection: auth_sessions (and __Host- / __Secure- variants) β€” not a legacy svelty_session name. Classifier stores the lane on event.locals.lane.

Operator guide + benchmarks: performance-architecture Β· Tests: unit classifier/cache + handle-turbo-get-lane, integration request-lane-headers, E2E hooks-lane-smoke.

Request β†’ classifyRequest (O(1))
  β”œβ”€ FAST_STATIC / HEALTH β†’ withLane(fast response)  [no full sequence]
  └─ other lanes β†’ getPipeline() sequence β†’ withLane(response)

πŸ—οΈ Middleware Pipeline (2026 Optimized)

The pipeline is dynamically dispatched based on system state and request lane, using pre-compiled, cached sequences:

  • API Lanes Pipeline (API_READ, API_WRITE, HYPER_TURBO - 14 hooks): Bypasses page-only SSR hooks (redirects, aeo-headers, user-preferences) while strictly preserving full security, rate limiting, authentication, authorization, and CSRF protection.
  • READY / APP_SSR Pipeline (17 hooks): Full production middleware chain for admin and SSR page rendering.
  • SETUP Pipeline (2 hooks): Minimal bootstrap chain during initial setup.
// API Lanes Pipeline (API_READ / API_WRITE / HYPER_TURBO):
const apiPipeline = [
  handleTurboPipeline, //  1. CONSOLIDATED GATEWAY & Security Headers
  handleSecurity, //  2. Firewall, GraphQL Shield, WAF Load Shedding
  handleRateLimit, //  3. Per-IP Fixed-Window Rate Limiting (mutations only)
  handleSystemState, //  4. AUTHORITATIVE GATEKEEPER
  handleTurboGet, //  5. πŸš€ TURBO GET (Session Cache + responseCache L1 for GETs)
  handleCompression, //  6. ESM-Native Streaming (Zstd/Brotli/Gzip)
  handleAuthentication, //  7. Identity, Session Token Validation (__Host-auth_sessions) β†’ sets locals.permMask
  handleAuthorization, //  8. RBAC: bitmask (Phase 2) + role bitset + legacy fallback, sets locals.roles
  handleLocalContext, //  9. Zero-Latency CMS Bridge (LocalCMS) & Content System per Tenant
  handleAuditLogging, // 10. Fire-and-Forget Audit Trails (mutations only)
  handleApiRequests, // 11. API Caching (ETags, L1 Sync Cache)
  handleTokenResolution, // 12. RBAC Token Replacement (reads only)
];

August 2026 Pipeline Hardening & Zero-Overhead Dispatch:

  • API Lane Fast-Path: API endpoints automatically bypass SSR-only hooks (handleRedirects, handleAeoHeaders, handleUserPreferences), saving 3 middleware invocations per API request.
  • Read vs. Write Execution Parity:
    • Read Operations (findById, find, list): Safe HTTP methods (GET, HEAD, OPTIONS) completely bypass CSRF checks (0Β΅s overhead), leverage synchronous L1 cache hits, and execute direct prepared statements.
    • Write Operations (create, update, bulk): Single CSRF verification via double-submit X-CSRF-Token header + __Host-csrf_token cookie, single schema LRU resolve, single-pass unified validation (prepareWritePayload), and detached non-blocking post-write hooks (schedulePostWrite).
    • Zero Redundant Checks: No duplicate permission queries, no multiple CSRF evaluations, and no duplicate schema fetches between the gateway and DB adapters.
  • Zero-Overhead Dispatch: wrapHandle passes raw resolved handlers directly to SvelteKit sequence(), eliminating nested timing closures unless ENABLE_HOOK_TIMING=1 is set.
  • Fast Guest Resolution: handleAuthentication detects credential-free safe reads and resolves guest locals in 0 microseconds without cookie scanning.
  • Asynchronous Audit Outbox: handleAuditLogging enqueues mutation entries into an in-memory ring buffer flushed via microtasks, decoupling response flushes from audit disk writes.

2026-09 Phase 2 β€” 64-Bit Bitmask Security Engine:

  • handleAuthentication now deserializes the session permMask hex string to locals.permMask: bigint (zero allocation β€” BigInt(hex)) and stores it in the session L0 cache alongside amr.
  • handleAuthorization forwards locals.permMask to setTurboAuthContext so the turbo fast-path carries the bitmask across request boundaries.
  • API dispatcher (+server.ts): _checkEndpointPermission accepts an optional permMask?: bigint. When present, it calls getPermissionBit(permission) β†’ hasPermissionBitmask(mask, bit) β€” evaluated as (mask & bit) !== 0n, a single CPU register operation with zero heap allocations. Falls back to the role-based LRU cache path for unmapped/dynamic permissions.
  • Cross-worker invalidation: SharedArrayBuffer + Atomics.compareExchange version counter ensures role bitset cache misses cascade to all Node.js worker threads within one event-loop tick.
  • FLAC reservation: bit 63 is permanently reserved for Field-Level Access Control (FLAC) β€” field:* permissions already route to the forward-compatible evaluateFieldPermission handler.

2026-07 Security Hardening: Turbo-auth restricted to GET/HEAD/OPTIONS (mutations pass through CSRF). x-tenant-id header only trusted when user is authenticated. Turbo-auth cache invalidated on role change, block, delete, and unblock. Auth methods use safeCall pattern (return DatabaseResult instead of throwing). invalidateTurboAuthForUser() exported for programmatic session revocation.

2026-06-27: handleRateLimit wired between handleSecurity and handleSystemState. timingSafeEqual for test secrets. API CSP tightened.

2026-06-05: handleSecurityHeaders inlined into handleTurboPipeline. handleTurboGet moved before auth/authz.

handleTurboPipeline - Consolidated Gateway

First hook. Classification, static delegation, setup-state gating, health fast-path, and the TEST_MODE-only terminal bypass (real session user resolution when a session cookie is present). No benchmark forge β€” benchmarks authenticate with real sessions like production.


πŸ“‘ Telemetry & Usage Tracking

SveltyCMS includes a minimal Heartbeat System (Telemetry) to help the core team understand usage patterns and enforce the BSL 1.1 license for enterprise users.

How it works

  1. Non-Blocking: The telemetry runs as a background process initialized in hooks.server.ts (outside the request pipeline).
  2. Frequency: It sends a heartbeat once every 12 hours per server instance.
  3. Efficiency: Uses Singleton patterns to preventing caching issues during Hot Module Replacement (HMR).
  4. Data Collected:
    • SveltyCMS Version (e.g., 1.0.0)
    • Hostname / Domain (e.g., cms.example.com)
    • Environment (Development vs Production)
    • Node.js Version
    • System Info (OS, CPU, Memory) for performance profiling
    • Approximate Location (Country/City) for usage heatmaps
    • Usage Metrics (User/Collection/Role counts)

Privacy & Opt-Out

We respect user privacy. Telemetry can be completely disabled by setting the following environment variable in your .env file:

# Disable SveltyCMS Telemetry
SVELTY_TELEMETRY_DISABLED=true
# OR use the universal standard
DO_NOT_TRACK=1

πŸš€ Server Startup & Initialization Flow

Optimized Startup Strategy

SveltyCMS employs a lazy initialization strategy to minimize server startup time and enable zero-restart setup completion:

sequenceDiagram
    participant Build as Vite Build
    participant Server as SvelteKit Server
    participant Hooks as hooks.server.ts
    participant DB as db.ts Module
    participant State as System State

    Note over Build,Server: Server Startup (Cold Start)

    alt During Build (!building = false)
        Build->>Hooks: Skip all initialization
        Note over Hooks: No DB import during build
    else Production Server (!building = true)
        Server->>Hooks: Server starts
        Hooks->>DB: Dynamic import('@src/databases/db')
        Note over DB: Module loaded but NOT initialized
        State->>State: Set state = IDLE
        Hooks-->>Server: Ready (no blocking)
    end

    Note over Server: First Request Arrives

    Server->>Hooks: handleSystemState()
    Hooks->>State: Check state

    alt State = IDLE && !initializationAttempted
        Hooks->>DB: await dbInitPromise
        DB->>DB: loadPrivateConfig()

        alt Config exists (normal startup)
            DB->>DB: initializeSystem()
            DB->>DB: Connect to database
            DB->>DB: Load adapters & models
            State->>State: Set state = READY
        else No config (first-time setup)
            DB->>State: Keep state = IDLE
            Hooks->>Hooks: Allow /setup paths only
        end
    end

    Hooks-->>Server: Continue request processing

First-Time Installation Flow (Zero Restart Required)

sequenceDiagram
    participant User as User/Browser
    participant Setup as /setup Wizard
    participant SF as Server Function: completeSetup
    participant FS as File System (private.ts)
    participant DB as db.ts
    participant State as System State
    participant Cache as Server Cache

    Note over User,Cache: Fresh Server (No Config)

    User->>Setup: Navigate to /setup
    Note over State: State = IDLE (allows /setup paths)
    Setup-->>User: Display setup wizard

    User->>Setup: Fill configuration form
    User->>Setup: Submit setup

    Setup->>SF: Call completeSetup()
    SF->>FS: Write config/private.ts (Keys, DB Config)
    SF->>FS: Read back real JWT_SECRET & ENCRYPTION_KEY

    SF->>DB: initializeWithConfig(fullConfigWithRealKeys)
    Note over DB: In-memory config passed<br/>(bypasses Vite cache)

    DB->>DB: privateEnv = config
    DB->>DB: initializeSystem(false, true)
    DB->>DB: Connect database
    DB->>DB: Load adapters
    DB->>DB: Setup auth models
    State->>State: Set state = READY

    SF->>DB: Create admin user
    SF->>Cache: Warm cache: fetch('/')
    Note over Cache: Pre-load homepage SSR

    SF-->>Setup: { success: true, redirect: '/login' }
    Setup->>User: Redirect to /login

    Note over User,State: βœ… System fully operational<br/>NO SERVER RESTART NEEDED

Key Implementation: hooks.server.ts

// --- Server Startup Logic ---
if (!building) {
  /**
   * Hardware Optimization:
   * `initHardwareProfile()` detects the machine ONCE (memory the OS actually
   * allows, CPUs this process may use) and publishes it to the shared registry;
   * everything else reads that object instead of probing again.
   *
   * `UV_THREADPOOL_SIZE` is NOT set here: libuv reads it before the loop starts,
   * so a runtime assignment is a no-op (measured: blocking pbkdf2 n=8/n=4 ratio
   * 1.69 set at runtime vs 1.25 pre-set). The entry (`index.cjs`) applies the
   * profile's derived value before the server starts.
   */
  initHardwareProfile();

  /**
   * ✨ ENTERPRISE: Graceful Shutdown Registry
   * Handles SIGTERM/SIGINT with an in-flight request drain period
   * and a 10s safety timeout.
   */
  process.on("SIGTERM", async () => {
    // ... wait for inFlightRequests to drain ...
    await shutdownSystem();
    process.exit(0);

Why Dynamic Import?

  • βœ… Non-Blocking: Server starts immediately without waiting for DB
  • βœ… Setup-Friendly: First-time installs don’t crash (IDLE state)
  • βœ… Hot Reload: Configuration changes don’t require restarts
  • βœ… Build-Safe: Skipped entirely during Vite build process

Zero-Restart Setup: initializeWithConfig()

Problem Solved: Traditional approach required server restart after creating config/private.ts because Vite caches module imports.

Solution: Pass configuration in-memory, bypassing filesystem imports:

// src/databases/db.ts
export async function initializeWithConfig(config: PrivateEnv): Promise<{ status: string }> {
  try {
    logger.info("Initializing system with provided configuration (bypassing Vite cache)...");

    // CRITICAL: Set config in memory BEFORE initialization
    privateEnv = config;

    // Now initialize system (skipSetupCheck = true means use in-memory config)
    initializationPromise = initializeSystem(false, true);
    await initializationPromise;

    return { status: "success" };
  } catch (error) {
    logger.error("Failed to initialize with config:", error);
    throw error;
  }
}

Called by: completeSetup Server Function in src/routes/setup/+page.server.ts after writing config/private.ts.

Benefits:

  1. Immediate Availability: System becomes operational instantly
  2. No Restart: Configuration takes effect without process restart
  3. Cache Warming: First page load happens during setup completion
  4. Smooth UX: Users redirected directly to login page

System-Level Operations

While the middleware pipeline enforces strict security and tenant isolation, some internal system processes operate outside standard request flows:

  • Initialization & Setup: The system verifies config existence directly rather than bypassing DB isolation to check for admins.
  • Background Maintenance: Tasks like demo-cleanup operate by querying the isolated tenant registry and processing tenants individually, never bypassing global tenant isolation.
  • Global Data: Operations on truly global data (like plugins) explicitly pass tenantId: null to signify global scope.
Important

There are no sudo or bypass backdoor flags in the database adapters. Standard API endpoints and user-initiated actions must always respect the middleware-enforced tenant isolation (event.locals.tenantId).

Performance & Throughput (Benchmarks)

SveltyCMS middleware is optimized for world-class micro-latency, utilizing fast-path short-circuiting for static assets and API requests. For a full breakdown of system metrics, see the Central Performance Benchmarks.

πŸš€ One-Shot Request Classifier

Every request is classified once at the top of the pipeline by classifyRequest() in handleTurboPipeline. This pre-computes five boolean flags (isStatic, isApi, isBootstrap, isPublic, isTestMode) and caches them on locals.__flags. All 15 downstream hooks read from this cache instead of re-running regex/prefix checks, eliminating redundant path classification overhead.

// In handleTurboPipeline (runs first):
classifyRequest(pathname, event.locals);

// In any downstream hook:
const flags = getRequestFlags(event.locals);
if (flags.isStatic) return resolve(event); // 0 redundant computes

πŸ”’ Security Hardening

  • dev bypass removed: Security checks now run in all environments. Dev mode previously allowed local requests to skip firewall/rate limiting entirely β€” a significant gap.
  • Explicit test-secret required: Only TEST_MODE=true or a valid x-test-secret header bypasses security on localhost. The x-test-security: true header forces full checks even in bypass-capable environments.
  • Cached dynamic imports: handleAuthorization and handleRedirects now pre-cache their lazy imports, eliminating repeated await import() overhead in hot paths.
  • Logger hot-path optimization: logger.debug and logger.info calls removed from request-hot paths; remaining calls gated with if (dev) or NODE_ENV !== "production" to prevent argument-evaluation overhead.

Middleware Pipeline Overhead

Full Pipeline End-to-End (SQLite, 2026-09-22, bun test tests/benchmarks/hooks-performance.test.ts):

Scenario Avg Latency p95 RPS
Static Asset (no hooks) 0.79ms 1.01ms 1,240
Turbo Pipeline (Light) 0.41ms 0.61ms 2,332
Full Security + Auth Pipeline 0.47ms 0.74ms 1,985
REST with API Caching (warm GET) 0.40ms 0.49ms 2,479
Mutation (audit logging disabled) 1.99ms 2.92ms 478

The measured middleware-only delta (full auth+security lane minus the light turbo lane) is +0.01–0.09 ms (10–90 Β΅s) per request on this machine; everything above that is HTTP framing and response serialization, which is also why a bare static asset is not faster than an API read.

Benchmark: BENCHMARK_RECORD=1 bun test tests/benchmarks/hooks-performance.test.ts. Rows move with machine load β€” the deltas between lanes are the signal, not the absolute values.

Auth & Middleware Attribution (2026-09-22)

ENABLE_HOOK_TIMING=1 plus readHookTimings()/hookPhaseCost() (tests/benchmarks/modules/benchmark-utils.ts) attribute a row to named pipeline stages from the server’s own counters (representative run, 2026-09-22, SQLite; single-run variance on this machine is Β±10%):

Phase turbo-pipeline security system-state turbo-get
reject 175 Β΅s 139 Β΅s 111 Β΅s 107 Β΅s
auth 123 Β΅s 86 Β΅s 63 Β΅s 59 Β΅s

⚠️ Read the columns as different work, and the spans as nested, not per stage.

  • Nested: sequence() composes the pipeline so each outer span contains every downstream hook (turbo-pipeline β‰ˆ the whole chain, turbo-get β‰ˆ everything after it). A change in total chain work shows up in every wrapper β€” the earlier reading of β€œevery wrapper inflates ~2Γ—, including the credential-blind ones” was that nesting artifact, not evidence of a per-stage cost.
  • Different work by construction: the auth column is the turbo-cache-served /api/user/me row (4 hooks), while a rejection runs the full chain to reach its 401 (7 hooks) β€” the harness prints that lane/cache/hook-count per row. Compare the wire numbers, not these spans; a rejection is not the slower path (see below).

Rejection cost parity (resolved 2026-09-22). A 401 used to cost ~2Γ— a valid read (every rejected flavour 0.73–0.78 ms vs 0.33–0.36 ms authenticated; two runs each side on the same build). The root cause was not in the middleware: the production 401/403 fast-path envelopes in src/utils/error-handling.ts were the only API responses built without a content-length. handleCompression size-gates on that header, so β€œunknown size” skipped both the < MIN_COMPRESSION_SIZE (1 KiB) skip-gate and the buffered/sync tier, and negotiated the streaming tier β€” a fresh createZstdCompress stream per reject (~0.45 ms for a 64-byte body, x-compression-algorithm: zstd + chunked). Accept-Encoding: identity restored the cheap path, which is what isolated it. Declaring the length puts a 64-byte envelope under the 1 KiB gate: reject rows now land at 0.35–0.39 ms, on par with the authenticated rows (0.33–0.36 ms turbo-served, 0.29–0.33 ms full chain).

Row context is part of the measurement now: auth-performance.test.ts prints a live lane/cache/hook-count table per row, because the rows are not equal work β€” Auth Validation @ 1c (/api/user/me) is served from the turbo cache (X-Cache: TURBO-HIT, 4 hooks), while a rejection must run the full chain to reach its 401 (7 hooks). Auth (full chain) @ 1c is the like-for-like authenticated row (same session cookie, a path the turbo cache never serves, 10 hooks), and the generated ledger picks all of it up on the next matrix run. The snapshot only works when the diagnostics health handler answers: the terminal bypass (handle-turbo-pipeline.server.ts:381/:403) returns a minimal payload without hooks, which the helpers report as unavailable instead of fabricating zeroes.

Response-site audit: content-length is a pipeline contract (2026-09-22)

The rejection fix above generalised: every API response built by hand must declare its length, or handleCompression treats it as β€œunknown size” and negotiates the streaming tier (fresh zlib/zstd stream per request) instead of skipping (< 1 KiB) or using the buffered sync tier (≀ 64 KiB). Compression is pipeline stage #6, so only responses from hooks downstream of it are compressed at all β€” handleSecurity, handleRateLimit, handleRedirects and handleTurboPipeline return before it and are never affected:

Response site Declares content-length Body class Verdict
routes/api/[...path]/handlers/base.ts buildJsonResponse βœ… computed (getFastByteLength) 0.1–250 KB already compliant β€” the standard success path
…/base.ts fastSuccessResponse βœ… computed 0.1–250 KB already compliant
utils/error-handling.ts staticJsonEnvelope βœ… computed 64 B fixed 2026-09-22 (unless rejection-cost parity)
hooks/handle-api-requests.ts serveCachedEntry (plain branch) βœ… computed from the body actually sent 0.1–250 KB fixed 2026-09-22 β€” it used to delete the stale length, so every cache hit was compressed as a stream
hooks/handle-api-requests.ts serveCachedEntry (compressed branch) βž– absent by design (binary variant; sets Content-Encoding) > 1 KB not applicable β€” compression early-returns on Content-Encoding
hooks/handle-local-context.ts 503 envelope βœ… computed 62 B fixed 2026-09-22 (DB-adapter-unavailable reject)
hooks/handle-token-resolution.ts rebuilt response βœ… computed after rewrite any JSON already compliant
routes/files/[...path]/+server.ts (206 + 200) βœ… declared from the file stat file bytes already compliant
routes/api/[...path]/handlers/streaming.ts, content.ts SSE βž– streamed by design unbounded text/event-stream is excluded from compression by content type; NDJSON/CSV exports stream
handleSecurity/handleRateLimit/handleTurboPipeline rejects βž– n/a < 1 KB upstream of stage #6 β€” compression never runs

Regression guard: tests/unit/hooks/api-requests.test.ts (serveCachedEntry content-length) pins the computed-length contract and the 304 no-body branch; tests/unit/utils/error-handling.test.ts does the same for the error envelopes.

Cold point-read attribution (2026-09-22) β€” lane refutation

The findByIdRandom competitive row is a different path from the rejects: a warm admin session on GET /api/collections/:collection/:entryId is served by the collection read lane (hooks.server.ts β†’ tryCollectionReadLane), which returns from handle directly β€” no middleware chain, and therefore no compression stage at all (the response only gets its content-length at socket-write time, when Node writes a single-chunk end()). Verified on-wire: lane HYPER_TURBO, x-cache: MISS, 676 B body + 26 headers (1279 B), content-length: 676.

Update (2026-09-28): the lane now owns its compression instead of skipping it. Inside the buffered budget (≀ 64 KiB) it compresses the negotiated encoding synchronously β€” the same tier this pipeline uses β€” and seeds the variant into the same L1 entry; later hits serve stashed zstd/br/gzip variants for free (the response-cache’s own variant precompute had been commented-out dead code, so no lane entry had ever carried one). Above 64 KiB the lane declines and lets the streaming tier compress while sending: buffering 255 KiB in-lane measured 10.8 ms/request vs 22.7 ms streamed, and the streamed figure is the app-wide tier’s cost β€” now measurable via api-latency.test.ts instead of skipped (see achievements Β§3.39). Implementation: src/hooks/response-compression-stash.ts.

PROFILE_WRITE=1 spans averaged over one full replica run (n = 6790 misses, SQLite, 10k docs, 8c) attribute the lane’s own work as follows (parent spans include queueing, leaves do not):

Stage avg Note
readLane:sdkFindById (parent) 127 Β΅s includes the two rows below plus await resumption
↳ sdk:crud.findById (adapter) 55 Β΅s of which db:get:sql 27 Β΅s (SQLite driver) + db:get:conv 12 Β΅s
↳ readLane:stringify/cacheSet 25 Β΅s one JSON.stringify + one point-cache insert
readLane:serve (parent) 69 Β΅s serveTurboCacheEntry: cached header template β†’ Headers β†’ Response
readLane:getLocals 9 Β΅s LocalCMS.getLocals constructs a fresh SDK facade per request
readLane:prep + cacheGet 11–12 Β΅s session/tenant/context + key build + L1 probe

The identical sites measured in isolation (Node 24, undici) cost ~9–14 Β΅s for the whole Headers + Response pair, so the in-server leaf numbers are ~4Γ— inflated by GC attribution while their relative order holds.

Refutation: no single step in the lane is provably unnecessary work, and none of it is large enough to close a ~70 Β΅s gap. Dedicated spans show what remains: process.cpuUsage-free sampling (Inspector Profiler, 36 k samples over one replica run) puts ~10 % of server CPU in undici web-platform plumbing (header validation/copy + body byte copies), ~4.5 % in all of our bundled chunks combined, ~12 % in the SQLite driver (node:sqlite returns 27 Β΅s where the raw bun:sqlite floor is 3–4 Β΅s), ~8 % in socket writes, ~2 % in GC. The repo’s own raw-HTTP floor (bun run scripts/benchmark-matrix/raw-db-ceiling.ts --db=sqlite --scale=1,10000) is 35.4 k RPS (28 Β΅s) for a findById-shaped bare-node:http + driver + 12 B JSON response, against a 3.2 k RPS (β‰ˆ310 Β΅s) CMS lane row β€” so the row’s remaining cost is framework (SvelteKit/adapter-node requestβ†’Request and Responseβ†’socket bridging), the SQLite driver and transport, not the lane’s own steps. Deliberately left alone: the point lane’s 2000-entry FIFO (a size/size tradeoff β€” at 100 k docs it serves ~2 %, at 10 k ~20 %, and enlarging it makes every collection write’s invalidation scan longer), LocalCMS per-request construction (9 Β΅s, shared-instance caching would put cross-request state in namespaces), and adapter-node’s response writer (vendored framework code, patched only for ORIGIN).

Superseded in part (2026-09-24) β€” three of the β€œdeliberately left alone” items are now settled, with measurements:

  1. LocalCMS per-request construction is removed from the lane. The lane holds one cached LocalCMS instance per process instead of a getLocals facade; the collections namespace is stateless per adapter, and tenancy stays request-scoped via applyAdapterTenantContext (the same step the lane already ran). Cross-request state never enters a namespace β€” the namespaces hold no request data, the request state lives on locals.
  2. The shared-cache L2 probe is gated. With skipCacheService (the lane’s own call shape) findById no longer probes cacheService.getSync β€” the lane caches the whole HTTP response in responseCache, so the probe was a guaranteed miss per cold id.
  3. adapter-node is patched at build time to dispatch the fast-lane registry (vite.config.ts adapterNodeBuildPatchPlugin), so node build/index.js gets the same lane dispatch as index.server.mjs β€” the transport win no longer depends on the entry point, and the vendored response writer stays untouched. Per-request new URL() in the lane is replaced by a minimal facade over exactly the five properties the lane reads. Replica at 100 k docs / 8c / PostgreSQL, same host, adapter-node entry: findByIdRandom 3 013 β†’ 4 470 – 4 553 RPS, findById 9 125 β†’ 13.6 k, listPlain 7 817 β†’ ~12 k, findMissing 9 363 β†’ ~16 k; create/update/graphql stayed inside the Β±8 % noise band. Byte-identity re-verified via BENCH_VERIFY_RAW=1 on the patched entry.

Point-read payload trimmed + acceptance gate re-scoped (2026-09-24, measured): the point-read HTTP payload dropped its SDK meta (_collection) and platform system columns (tenantId, isDeleted, createdAt, updatedAt, plus null mirror columns) through one shared helper (src/utils/point-read-payload.ts) wired into the lane’s MISS rebuild, the dispatcher fallback AND the predictive turbo stash β€” so BENCH_VERIFY_RAW=1 byte-parity holds and the probe-shaped body is 1 161 B (~247 B trimmed). The cold split re-measured on the bench-db (Postgres, 360 k rows): lookup 0.010 / db 0.560 / build 0.570 / cachewrite 0.580 / serve 0.630 ms p50 β€” the app-side miss-path work (lookup+build+cachewrite+serve minus db) is now ~0.07–0.08 ms, under the 0.30 ms gate target in absolute terms; the remaining cold-path cost is the DB read itself (0.56 ms over TCP on the bench host), which the driver-ceiling map already owns. The gate stays: a change that does not move the app-phase sum is not a read-path win.

Phase-mark semantics (2026-10-12): each x-srv-split value is now the phase’s own duration (a delta). Previously build/cachewrite on the read lane were cumulative from the DB anchor, which the replica’s phase table (it averages marks directly) read as >900% dominance; lookup/db/build/cachewrite are deltas now, serve/persist alone stay cumulative from request start. The chain recorded above is historical under the old instrumentation.

Machine-noise band (stated honestly): three runs per side of a fingerprint-pinned build (BENCH_DOCS=10000 BENCH_CONCURRENCY=8 bun test tests/benchmarks/competitive-workload-replica.test.ts, SQLite; runs whose build artifact changed mid-flight are discarded) give findByIdRandom warm sequential 0.430 / 0.451 / 0.515 ms before and 0.356 / 0.443 / 0.469 ms after, warm 8c 2.215 / 2.236 / 2.273 ms before and 2.266 / 2.384 / 2.698 ms after β€” medians 0.451 vs 0.443 ms (seq) and 2.236 vs 2.384 ms (8c), i.e. Β±13–20 % run-to-run noise that swallows the difference in both directions. The single 2.698 ms outlier is the one run taken while a concurrent benchmark was active on the same machine; the same build re-measured 2.266 ms. The two audit fixes do not sit on this row’s code path by construction (lane-served reads never enter the compression stage, serveCachedEntry and handleLocalContext are pipeline-only), so the pair is a regression check, not an improvement β€” and it shows no regression.

Write-lane decomposition β€” x-srv-split sub-phases + x-srv-dur (2026-09-24)

The write lane’s first split (security / persist / serve) proved the security phase costs p50 0.010–0.020 ms per write β€” but β€œpersist” was still a black box containing everything between request.json() and the envelope. The lane now stamps the same x-srv-split header with per-request sub-phase durations handed in via LocalApiOptions.__phaseMarks (internal, absent by default β€” the performance.now() call sites are skipped entirely when no map is passed):

Phase Measured where
security lane: WAF inspect + CSRF + turbo-session + tenant context (cumulative from t0)
parse lane: request.json() + envelope unwrap
schema namespace: peekReadySchema/schemaOf + ensureWidgets + hot flags
prep namespace: prepareWritePayload (sanitize + validate + defaults) + publication gate + beforeSave + widget pipeline
encrypt namespace: encryptWritePayload (no-op when the schema has no encrypted fields)
dbwrite namespace: persistWithOutbox β€” the adapter INSERT/UPDATE incl. outbox scheduling
postwrite namespace: decryptReadResult + schedulePostWrite + list-warm scheduling
persist lane: the whole SDK create/update call (cumulative from t0)
serve lane: envelope + security headers (cumulative from t0)

update additionally stamps snapshot (the pre-write revision read β€” only present when the collection enables revision). SVELTY_SRV_DUR=1 now also stamps total server time (x-srv-dur) on write-lane responses, so hot writes are comparable against the read lane’s acceptance gate. Collect the p50s per phase with tests/benchmarks/probe-write-split.test.ts (see its header for the env recipe).

Reusable CPU sampling for soak runs (2026-09-23)

Attributing a lane needs V8 samples from a replica run, and node --cpu-prof cannot supply them: V8 writes its .cpuprofile only on a clean exit, so a soak or benchmark harness that SIGTERMs its child and SIGKILLs it a moment later leaves no file at all.

SVELTY_CPU_PROFILE=<dir> closes that gap. Sampling starts at boot, and every SVELTY_CPU_PROFILE_INTERVAL_MS (default 30 s, floor 1 s) the profiler stops, writes the snapshot atomically (.tmp + rename, so a hard kill can never leave a truncated file as the newest one), keeps the newest two, and resumes. The profile on disk is therefore at most one interval old regardless of how the process dies. Unset, the switch costs one env read at boot and imports nothing else.

SVELTY_CPU_PROFILE=prof/soak SVELTY_CPU_PROFILE_INTERVAL_MS=5000 bun run preview
# β†’ prof/soak/cpu-<iso stamp>-000.cpuprofile  β†’  Chrome DevTools / speedscope

Snapshots are ordinary V8 profiles (nodes, samples, timeDeltas), so the same tooling that reads --cpu-prof output reads them. tests/unit/utils/cpu-profiler.test.ts pins the disabled path, snapshot loadability and the rotation bound.

Competitive Comparison (2026)

Comparative overhead figures must be like-for-like: our own numbers above are HTTP end-to-end per request (0.4–2.0 ms), while the rows below describe middleware-chaining overhead in other stacks. Those are different measurement domains and are not a ranking β€” treat the table as a pointer to external documentation, not as a measured comparison (see AGENTS.md Β§2b on comparative advertising).

Stack / Middleware Style Typical Overhead (per request) Source
Minimal Hono / Elysia 2–12 Β΅s framework documentation (2026)
Fastify (Rate-limit + 4 plugins) 15–60 Β΅s framework documentation (2026)
Next.js Middleware (Typical) 40–250 Β΅s framework documentation (2026)
Express (Auth + Security + Logger) 80–300 Β΅s framework documentation (2026)

Optimization Impact

Metric Before (v2025) After (v2026) Gain
Full Auth Pipeline (hooks) 0.780ms 0.617ms πŸš€ 21%
Full Auth p95 (hooks) 1.23ms 0.883ms πŸš€ 28%
Turbo Pipeline (hooks) 0.59ms 0.527ms πŸš€ 11%
Hot Reads (findById) 6.87ms 3.81ms πŸš€ 44.6%
Collection List 6.49ms 4.43ms πŸš€ 31.8%
Collection Search 5.98ms 4.38ms πŸš€ 26.8%
RBAC Permission RPS 12.1M/s 15.2M/s πŸš€ 24.9%
Indexed Filtered Query baseline -80% avg πŸš€ 5Γ— faster

Latency Tiers (2026-07 ledger verified):

Tier Latency p95 RPS When
Static Asset (no hooks) 0.085ms 0.127ms 9,776 CSS/JS/images
Turbo Pipeline (light) 0.527ms 0.652ms 1,569 Health checks, CORS, setup
Full Auth Pipeline 0.780ms 0.883ms 1,176 First request, cold session
REST API Cache HIT (ETag + 2-layer) 0.765ms 1.046ms 1,113 2nd+ identical API GET
Mutation + Audit 2.924ms 3.510ms 342 Content saves, media uploads

Turbo GET Fast-Path (handleTurboGet):

  • Positioned after security gates (rate limiting, firewall) but before auth/authz.
  • On cache hit, skips handleAuthentication, handleAuthorization, CSRF, tenant resolution, and all dynamic imports β€” reducing warm-path latency from ~3.5ms to ~0.9ms (~4Γ— faster for admin browsing).
  • Universal auth context cache (60s TTL, 1000-entry LRU) makes ALL subsequent requests skip auth overhead, not just cacheable GETs.
  • Security: rate limiting and firewall still apply; security headers included in response; session revocation wired into invalidation.

Per-Hook Latency Breakdown (measured via getHookTimings() on /api/system/health):

Hook Est. cold cost Turbo HOT cost Note
hyper-turbo ~0.01ms β€” Env-gated, skipped in prod
turbo-pipeline ~0.80ms ~0.80ms Classification + state + health + CORS
security-headers ~0.02ms β€” Header setting; turbo response includes its own
test-isolation ~0.01ms β€” Test-only
security ~0.05ms ~0.05ms Rate limit + firewall (runs before turbo)
aeo-headers ~0ms (API) β€” HTML-only: early-returns non-HTML responses (previously ~43Β΅s per API response)

| system-state | ~0.02ms | ~0.02ms | isSystemReady() β€” module-level cached | | redirects | ~0.02ms | ~0.02ms | Materialized view check | | compression | ~0.05ms | ~0.05ms | Streaming setup; >1KB API variants pre-computed (br/gzip/zstd) and stashed into the L1 turbo entry β€” TURBO-HIT serves the stored variant instead of re-compressing (~17-27Β΅s/KB saved on >4KB payloads) | | turbo-get | 0.02ms (miss) | 0.02ms (hit) | 5 gate checks; returns pre-encoded on HIT, passthrough on MISS |

| user-preferences | ~0.01ms | skipped | Cookie check for i18n/theme | | authentication | ~2.0ms | skipped | Main cost center: session DB + tenant + CSRF | | authorization | ~0.5ms | skipped | Role loading + permission checks |

| local-sdk | ~0.01ms | ~0.01ms | LocalCMS context injection | | content-init | ~0.01ms | ~0.01ms | Per-tenant content manager setup | | audit-logging | ~0.02ms | ~0.02ms | Buffer push (mutation-only, skipped on GET) | | api-requests | ~0.30ms | ~0.30ms | ETag (XXH3 via hash-wasm) + cache dispatch + response handling | | token-resolution | ~0.01ms | ~0.01ms | Post-processing RBAC token replacement | | Total | ~3.9ms | ~0.9ms | ~4.3Γ— faster on turbo HIT |

Hook timing diagnostics are now live at GET /api/system/health under the hooks key (avg/min/max/count per hook). Use this in production to identify bottlenecks and validate pipeline optimizations.


✨ handleTurboPipeline - Consolidated Gateway

File: src/hooks/handle-turbo-pipeline.server.ts Purpose: First-line consolidated gateway handling classification, state gating, health checks, and CORS in a single pass for minimal overhead.

Pipeline Order (within the hook itself):

  1. One-Shot Request Classifier β€” Computes isStatic/isApi/isBootstrap/isPublic once, stored in locals.__flags for all downstream hooks.

  2. Terminal Test Bypass β€” Cryptographic x-test-secret verification; resolves real user from session for test isolation.

  3. Terminal Health Check Bypass β€” /health and /api/system/health return instantly with DB + memory diagnostics.

  4. Static Asset Delegation β€” Regex short-circuit for /_app/, /static/, /files/ paths.

  5. System State Gate β€” Blocks when INITIALIZING (waits for CORE boot) or FAILED (returns 503).

  6. Setup Completeness Gate β€” Redirects to /setup if config missing; blocks /setup if already complete.

  7. Bootstrap Route Bypass β€” /setup and /login skip remaining middleware during installation.

  8. CORS Preflight Fast-Exit β€” the single canonical OPTIONS handler for every /api/ route (allowlist validation + CORS + security headers + request-id). The API dispatcher and handlers intentionally have no preflight logic.

Key Features:

  • Zero-Latency Health Checks: Cached DB adapter reference avoids import overhead on every health poll.
  • Request Tracing: Injects a unique X-Request-ID into event.locals and all early-exit responses.
  • Localized Setup Support: Handles /en-US/setup and other i18n-prefixed bootstrap routes.

handleCompression - ESM-Native Optimization (Pipeline #10)

File: src/hooks/handle-compression.ts Purpose: Efficiently compresses outgoing responses using GZIP or Brotli.

Key Features:

  • ESM-Native: Uses dynamic await import() for node:zlib, ensuring compatibility with SvelteKit’s ESM-only production bundles.
  • Streaming-Safe: Works with standard SvelteKit response streams.
  • Lazy Initialization: Modules are only loaded if the runtime supports them (Edge vs. Node).

handleTurboGet - Turbo GET Fast-Path πŸš€

File: src/hooks/handle-turbo-get.ts
Classifier: Only meaningful when the request was already tagged HYPER_TURBO (authenticated GET/HEAD on cacheable prefixes).
Purpose: Serves pre-encoded cached API responses BEFORE the auth/authz middleware chain, using a pre-computed session auth context cache + user-scoped responseCache L1 body/ETag.

Architecture:

Request β†’ classifyRequest β†’ … β†’ security β†’ rate-limit β†’ system-state β†’ ⚑ TURBO GET ⚑
  β”œβ”€ Gate 1: GET/HEAD/OPTIONS only (no mutation bypass)
  β”œβ”€ Gate 2: Cacheable API path? (CACHEABLE_PREFIXES: collections, content, settings, system, graphql, media, dashboard, …)
  β”œβ”€ Gate 3: Session cookie (`auth_sessions` / Host|Secure prefix) or Authorization present?
  β”œβ”€ Gate 4: Auth context in turbo cache? (60s TTL, LRU)
  β”œβ”€ Gate 5: Response body in responseCache L1 (user-scoped key)?
  β”œβ”€ HIT  β†’ Return pre-encoded response + security headers + x-svelty-lane (skip auth/authz)
  └─ MISS β†’ Inject auth context into locals β†’ passthrough to normal pipeline

Universal Auth Context Cache:

The handleAuthentication hook also checks the turbo auth cache at its entry point (before any dynamic imports, tenant resolution, or CSRF work). When populated, this skips ~2ms of auth overhead for ALL request types β€” not just cacheable GETs.

Cache Population: The auth context ({ user, roles, bitset, tenantId }) is populated by handleAuthorization after successful role resolution. Both admin fast-path and non-admin role-loaded paths populate it.

Latency Tiers:

Tier Latency When
Cold (no cache, fresh session) ~4.2ms First request after restart
Warm L1 (response cached, pays auth) ~3.5ms Repeat GET, auth still runs
Turbo HOT (response + auth cached) ~0.9ms 2nd+ admin page load (~4.7Γ— faster)

Security Guarantees:

Concern Protection
Rate-limit abuse Runs after handleSecurity (rate limiter + firewall)
Security headers applyAllSecurityHeaders() on turbo HIT response
Session revoked invalidateTurboAuthContext() wired into invalidateSessionCache()
Role changed 60s TTL auto-expiry, then full re-validation
Mutation bypass Gate 1 rejects POST/PUT/DELETE immediately
Cross-tenant access Auth context keyed by session ID + tenant

File reference: src/hooks/handle-turbo-get.ts, src/hooks/handle-authentication.ts (universal check), src/hooks/handle-authorization.ts (cache population)


Collection write lane

A warm admin session is served by tryCollectionWriteLane / tryGraphqlWriteLane on the SvelteKit API_WRITE path, before the ten-hook chain. A cold session falls through with the body unread. The socket twin stays off unless SVELTY_FAST_LANE_WRITE=1.

The lane admits POST /api/collections/:id, PATCH/PUT /api/collections/:id/:entryId, and the same collection’s batch, batch-clone, bulk, increment, and status actions, plus POST /api/graphql. A non-admin on an extended action falls through before the body is read. The lane still applies the operational-state gate, the header and path scan, the declared body cap, CSRF once (GraphQL does not check the token a second time), the user-agent step-up, the admin check, and the mutation limiter. Prefer: return=minimal skips the row read-back on create, update, increment, and status. With no Prefer header the response is the full document. The full-document PostgreSQL update stays dominated by the durable commit; this lane does not change that commit.

Collection Read Lane β€” bypass policy, gates & the WAF gap

src/hooks/fast-lane.server.ts registers a lane that answers GET|HEAD /api/collections/:collection[/:entryId] from a warm turbo-auth session before handle’s pipeline runs. It is not a second implementation: it calls the same tryCollectionReadLane the pipeline calls, only the transport differs, and BENCH_VERIFY_RAW=1 proves both transports byte-identical in one server run. The read lane is default on (SVELTY_FAST_LANE=0 opts out); the write lane is opt-in (SVELTY_FAST_LANE_WRITE=1) because it carries mutation bodies. For the request-class framing β€” which reads belong to the Wire Plane and which to the Domain Plane β€” see Two-Plane Core.

Gates, all evaluated before any cache lookup (so a lane-served body can never outlive a policy change):

Gate Rule
Operational state lane-state-gate.ts re-applies handle-system-state’s predicate (READY/WARMED/WARMING/DEGRADED serve) β€” consulted by the lane, its peer and the registry dispatch
Endpoint parity collections:read only β€” exactly what ENDPOINT_PERMISSIONS requires for /api/collections GET
Grant freshness isPermissionBitsetStale(turbo.permRev) declines when a role/permission mutation bumped the global epoch (the turbo mask is a snapshot)
FLAC exemption isLaneFlacExempt() declines when a FIELD_PERMISSIONS policy applies or the schema declares a guarded field (readRoles, writeRoles, requiredAuth, hidden, or permissions.visibility of private or hidden). The lane never reaches handleTokenResolution.
Publication clamp Wire reads pass requirePublished: true for publication-clamped callers; the engine compiles status = 'publish' and fails closed where a table has no status column
Wire admission isWirePlaneAdmissible() β€” published status (no draft/preview flags or cookies), no populate, default-locale alignment, no mutating afterRead/afterFind hooks, default query shape, fields equal to the compiled projection

LANE_BYPASSED_HOOKS lists every pipeline hook lane traffic never reaches; a guard test fails when a hook is added without being classified, so the divergence stays a decision rather than an accident.

Note

Threat scan on the lane β€” security stays in that list, so lane-served reads do not run heap shedding, the Redis WAF limiter, or the GraphQL shield. Before any cache or database read the lane calls wafGuard.inspectEvent (path, query, and headers) and returns the same empty 200 for a honeypot path or a non-local scanner or AI-crawler user agent. Rate limiting is unchanged: the API_READ pipeline already skips it for GETs.


handleSecurity - Unified Threat Protection (Pipeline #4: Firewall + Rate Limiting + GraphQL Shield)

File: src/hooks/handle-security.ts Purpose: High-performance, consolidated security layer combining firewall, distributed rate limiting, payload analysis, GraphQL complexity shielding, and load shedding.

Core Responsibilities:

  • Rate Limiting (Global): All requests pass through securityResponseService.analyzeRequest() which performs IP-based, IP+UA, and cookie-based rate limiting with Redis-backed distributed state. Returns action: "block" (403) or action: "challenge" (429 with Retry-After header) when limits are exceeded.

  • Firewall & Payload Inspection: ReDoS-safe scanning of JSON, Forms, and URLs for SQLi/XSS/Command Injection. Large bodies (>10MB) rejected before memory allocation.

  • AST-Level GraphQL Complexity Shield: 2-phase analysis β€” ultra-fast string pre-filter (<0.01ms) catches obvious recursion bombs; full AST parser calculates depth Γ— fieldCost Γ— listMultiplier to block queries exceeding MAX_COMPLEXITY (1000). This prevents attacks like users(first: 99999) { posts(first: 99999) { ... } }.

  • Load Shedding (Memory Pressure): When heap usage exceeds 98%, mutation requests (POST/PUT/PATCH/DELETE) are rejected with 503 + Retry-After: 30 to protect read availability. System-critical routes (/api/system, /setup) are exempt.

  • AI Crawler Honeypot (IP Flagging): Detects automated reconnaissance bots hitting common shadow routes (e.g., /wp-admin, /.env). Returns a decoy 200 OK immediately and flags the IP via securityResponseService.blockIp() so the next request is rejected at the firewall layer. (The old 5–15s tarpit was removed β€” holding sockets open made the honeypot itself a Slowloris/DDoS vector.)

Test Mode Bypass: Only TEST_MODE=true with a valid x-test-secret header on localhost bypasses security. The x-test-security: true header forces full checks even in bypass-capable environments. In production, the full security pipeline always runs.

Rate Limiting Implementation:

Rate limiting is handled at two levels for defense-in-depth:

  1. Global (in handle-security.ts): securityResponseService.analyzeRequest() applies IP/IPUA/cookie rate limits to all requests via the centralized security pipeline. /api/commerce/* maps to a dedicated WAF limiter (60/min).
  2. Hook lane (handle-rate-limit.ts): Per-IP + per-tenant fixed window. Guest /api/commerce mutations use an isolated commerce lane (tighter cap, X-RateLimit-Lane) so cart floods cannot exhaust admin APIs.
  3. Per-endpoint (e.g., auth actions in login/+page.server.ts): Critical endpoints apply additional RateLimiter instances from @src/hooks/handle-rate-limit for targeted protection.
export const handleSecurity: Handle = async ({ event, resolve }) => {
  // ... test mode bypass, memory load shedding, GraphQL complexity check ...

  // 1. Analyze request for threats (Firewall + Payload Scan + Rate Limiting)
  const securityStatus = await securityResponseService.analyzeRequest(request, clientIp, tenantId);

  if (securityStatus.action !== "allow") {
    const statusCode = securityStatus.action === "block" ? 403 : 429;
    // Returns structured error with Retry-After for 429 responses
    throw error(statusCode, securityStatus.reason || "Forbidden");
  }

  return resolve(event);
};

Performance: Full pipeline p95 is 0.883ms (ledger 2026-07-05, hooks-performance.test.ts on SQLite; 0.668ms on warmed matrix shared-server runs). Early exit for legitimate traffic skips payload scanning; GraphQL complexity pre-filter catches attacks in <0.01ms. See SQLite Benchmark Ledger for historical trends.

Legitimate Bots Allowed:

  • Googlebot, Bingbot, DuckDuckBot
  • Baiduspider, YandexBot
  • Social media crawlers (Facebook, Twitter, LinkedIn, WhatsApp, Telegram, Discord)

Setup Completeness Gating (Pipeline #2 + #6: TurboPipeline + SystemState)

Files: src/hooks/handle-turbo-pipeline.server.ts + src/hooks/handle-system-state.ts Purpose: Ensures CMS installation is complete before serving application routes. There is no standalone handleSetup.ts β€” setup gating is split across two hooks for defense-in-depth.

Flow: Config Missing β†’ /setup redirect β†’ Wizard Complete β†’ Normal Operation

Two-Layer Gating:

  1. TurboPipeline (Early) β€” For non-setup routes it uses the fast isSetupComplete(): if config/private.ts is missing, redirects all non-setup routes to /setup with a 302. For /setup requests it resolves the DB-aware state (getSetupState()), so the page is only blocked once an admin account + roles exist. config/private.ts is written during the DB test/seed (before the admin is created), so mere file presence must not count as β€œcomplete” β€” the /setup page’s own load likewise redirects to /login only on SetupState.COMPLETE. An abandoned half-setup therefore keeps /setup reachable instead of locking the user out.

  2. SystemState (Later) β€” Acts as the authoritative gatekeeper. Verifies system state transitions (IDLE β†’ SETUP β†’ READY). Blocks /api/setup with 403 after setup completes. Handles the INITIALIZING wait with a 60s timeout.

Key Features:

  • Config Validation: Checks both existence and content of config/private.ts
  • Zero-Restart Switching: Dynamic setup state tracked via __SVELTY_SETUP_COMPLETE__ global flag
  • Asset Allowance: Static assets allowed during setup for UI rendering
  • Localized Setup Support: Handles /en-US/setup and other i18n-prefixed bootstrap routes
  • Test Mode Exemption: TEST_MODE=true bypasses the β€œsetup complete” block for integration tests

Root Path (/) Redirect Flow

The root path has special handling to provide an optimal user experience across all system states:

Redirect Logic Sequence:

  1. No config/private.ts (or empty values)

    • handleSetup β†’ Redirect to /setup
    • User completes installation wizard
  2. Has config/private.ts, no valid session, SITE_STARTER_ENABLED

    • handleSetup β†’ Passes through
    • handleAuthentication β†’ Sets locals.user = null
    • (site)/+page.server.ts β†’ SSR public homepage (pages entry with slug: home)
  3. Has config/private.ts, no valid session, site starter disabled

    • (site)/+page.server.ts β†’ Redirect to /login
  4. Has config/private.ts, valid session

    • handleSetup β†’ Passes through
    • handleAuthentication β†’ Sets locals.user = User
    • (site)/+page.server.ts β†’ Redirect authenticated editors to first collection (or Collection Builder for admins)
Note

The legacy root loader at src/routes/+page.server.ts was removed to avoid a SvelteKit route conflict with src/routes/(site)/+page.server.ts β€” both mapped to /. CMS redirect logic now lives in redirectAuthenticatedUserToCms() inside the site starter loader.

Implementation (src/routes/(site)/+page.server.ts):

// Guests: resolve published homepage via resolveSitePage()
// Editors: redirectAuthenticatedUserToCms() β†’ first collection or /config/collectionbuilder
export const load: PageServerLoad = async ({ locals, parent, url }) => {
  if (!isSiteStarterEnabled()) {
    if (!user) throw redirect(302, "/login");
    return redirectAuthenticatedUserToCms(locals, url);
  }
  if (user && !user.isAnonymous) {
    return redirectAuthenticatedUserToCms(locals, url);
  }
  // ... resolveSitePage({ pathname: "/" }) for public SSR
};

Why Root Path is Always Allowed:

The root path (/) must pass through handleSystemState during IDLE and INITIALIZING states to enable:

  • Setup wizard detection and redirect
  • Login page redirect for invalid sessions
  • First collection redirect for authenticated users

Blocking / during initialization would prevent these essential flows from working.


handleUserPreferences - i18n & SSR Theme (Pipeline #12)

File: src/hooks/handle-user-preferences.ts Purpose: Validates language cookies and applies the SSR theme class to prevent FOUC.

Why consolidated: Previously two separate hooks (handleLocale and handleTheme) β€” merged into one to reduce Promise chain overhead in the middleware pipeline.

Key Features:

  • SSR-Safe Language Resolution: Resolves systemLanguage / contentLanguage cookies onto request-scoped event.locals β€” never a module-global store. (Mutating the app singleton on the server leaked one request’s language to every concurrent render in the same process.)
  • Cookie Validation: Validates against supported locales; auto-deletes invalid cookies.
  • SSR Theme Injection: Injects class="dark" into <html> tag via transformPageChunk only when needed, preventing Flash Of Unstyled Content (FOUC).
  • ThemeManager Integration: Loads custom CSS from DB-based ThemeManager for white-label tenants.
  • Fast-Path for API/Static: Skips entirely for API routes and static assets using pre-computed flags.
export const handleUserPreferences: Handle = async ({ event, resolve }) => {
  // Skip for API/static routes via pre-computed flags
  const flags = getRequestFlags(locals);
  if (flags.isApi || flags.isStatic) return resolve(event);

  // 1. Validate language cookies β†’ request-scoped locals (NO global store mutation)
  const systemLangCookie = cookies.get("systemLanguage");
  if (systemLangCookie && !isValidLocale(systemLangCookie)) {
    cookies.delete("systemLanguage", { path: "/" });
  } else {
    event.locals.systemLanguage = systemLangCookie;
  }

  // 2. Load theme from DB (custom CSS for white-label)
  const themeManager = ThemeManager.getInstance();
  event.locals.customCss = (await themeManager.getTheme(tenantId)?.customCss) || "";

  // 3. Inject dark class only when needed (skip transformPageChunk otherwise)
  if (themePreference !== "dark") return resolve(event);
  return resolve(event, {
    transformPageChunk: ({ html }) =>
      html.replace('<html lang="en" dir="ltr">', '<html lang="en" dir="ltr" class="dark">'),
  });
};

handleAuthentication - Identity, Sessions & Multi-Tenancy (Pipeline #13)

File: src/hooks/handle-authentication.ts Purpose: Manages user identity, session security, and tenant isolation.

Key Responsibilities:

  • Multi-Tenancy:

    • Standard Mode: Resolves tenantId from the hostname (subdomain).
    • Demo Mode: Checks for SVELTYCMS_DEMO environment variable or DEMO private setting. If active, it looks for a demo_tenant_id cookie.
      • If missing, it generates a new UUID tenantId, sets the cookie (60 min TTL), and automatically seeds the new tenant with default data (roles, settings, admin user).
      • In this mode, users can register without a token, and the registration UI is optimized to hide the token field.
  • Session Validation: Verifies the session_id cookie against the database/cache.

  • Tenant Isolation: Ensures the authenticated user belongs to the resolved tenantId.

  • Global Administrator: Recognizes and preserves the global administrator (tenantId: null), who is exempted from automated cleanup processes.

  • Session Rotation: Rotates session tokens every 15 minutes for active users (industry standard).

  • WeakRef Caching (Memory Stability): Uses a memory-efficient LRU cache (top 100 hot sessions) utilizing WeakRef for User objects. This ensures that the V8 garbage collector can immediately reclaim memory from inactive sessions, preventing the memory bloat and OOM crashes common in Strapi and Payload.

// Simplified Logic
const isDemoMode = getPrivateSettingSync('DEMO');

if (multiTenant || isDemoMode) {
    let tenantId: string | null = null;

    if (isDemoMode) {
        // Demo Mode: one seeded tenant per visitor (DEMO implies tenant isolation)
        tenantId = cookies.get('demo_tenant_id');
        if (!tenantId) {
            tenantId = crypto.randomUUID();
            cookies.set('demo_tenant_id', tenantId, ...);
            await seedDemoTenant(dbAdapter, tenantId); // Auto-seed new tenant
        }
    } else {
        // Standard Mode: Tenant ID from hostname
        tenantId = getTenantIdFromHostname(url.hostname);
    }
    locals.tenantId = tenantId;
}

Key Features:

  • πŸš€ Universal Turbo Auth Check: Before any dynamic imports, tenant resolution, or CSRF work, handleAuthentication checks the turbo auth context cache (handle-turbo-get.ts). On a warm cache hit, it injects { user, roles, tenantId } and skips ALL auth logic β€” saving ~2ms per request for ALL request types, not just GETs.
  • WeakRef-based automatic garbage collection
  • LRU cache for top 100 hot sessions
  • 3-layer caching (in-memory β†’ Redis β†’ database)
  • Multi-tenancy with hostname-based tenant ID
  • Tenant isolation enforcement
  • Automatic session rotation every 15 minutes for security
  • Rate-limited rotation attempts to prevent abuse

handleAuthorization - Role-Based Access Control (Pipeline #14)

File: src/hooks/handle-authorization.ts Purpose: Enforces granular permissions and protects routes based on user roles.

export const handleAuthorization: Handle = async ({ event, resolve }) => {
  const { url, locals } = event;
  const { user } = locals;
  const isApi = url.pathname.startsWith("/api/");
  const isPublic = isPublicRoute(url.pathname);

  // 1. Skip internal/public routes
  if (url.pathname.startsWith("/.well-known/") || url.pathname.startsWith("/_")) {
    return resolve(event);
  }

  if (isPublic) {
    locals.isAdmin = false;
    locals.hasManageUsersPermission = false;
    return resolve(event);
  }

  // 2. Load roles and check admin status
  const rolesData = await getCachedRoles(locals.tenantId);
  locals.roles = rolesData;

  if (user) {
    const userRole = rolesData.find((r) => r._id === user.role);
    const isAdmin = !!userRole?.isAdmin;

    locals.isAdmin = isAdmin;
    locals.hasAdminPermission = isAdmin;
    locals.hasManageUsersPermission =
      isAdmin || hasPermissionByAction(user, "manage", "user", undefined, rolesData);

    // Redirect authenticated users away from public pages (login/register)
    if (isPublic && !isApi) {
      throw redirect(302, "/");
    }
  } else {
    // 3. Handle unauthenticated users
    if (!isPublic) {
      if (isApi) throw new AppError("Unauthorized", 401, "UNAUTHORIZED");
      throw redirect(302, "/login");
    }
  }

  return resolve(event);
};

Key Features:

  • Granular RBAC: Validates actions against user roles and permissions.
  • Tenant Isolation: Uses locals.tenantId to ensure data boundaries.
  • Efficient Redirects: Smartly moves unauthenticated users to login or root.
  • πŸš€ Turbo Auth Population: After successful role resolution, calls _populateTurboAuth() to cache { user, roles, bitset, tenantId } keyed by session ID. This enables subsequent requests (within 60s TTL) to skip both handleAuthentication and handleAuthorization entirely via the universal turbo auth check.
  • Admin Fast-Path: Admin users skip role loading entirely β€” their turbo context is populated with an empty roles array since the dispatcher’s admin check bypasses permission resolution.

handleApiRequests - API Authorization & Caching (Pipeline #18)

File: src/hooks/handle-api-requests.ts Purpose: Authorizes API requests and caches GET responses with ETag-based conditional revalidation.

See Standard Middleware: handleApiRequests below for full implementation details including ETag generation, apiData fast path, SWR pre-warming, and X-Cache header values.


handleLocalContext - Zero-Latency CMS Bridge & Content Initialization (Pipeline #15)

File: src/hooks/handle-local-context.ts Service: @src/services/local-cms, @src/content/index.server Purpose: Injects the LocalCMS SDK into locals.cms and ensures tenant content system initialization in a single unified atomic middleware step.

Key Features:

  • Service-Based: Decoupled from the routing layer to prevent circular dependencies.
  • Zero-Latency: Direct database adapter access without HTTP overhead.
  • Context Injection: populates event.locals.cms for use in all downstream load functions and actions.
  • Tenant-Aware: Automatically initializes with the current tenantId resolved in earlier middleware.
  • Consolidated Hop: Replaces two separate legacy handles (handleLocalSdk + handleContentInitialization) with a single async execution, eliminating unnecessary microtask allocations.

handleTokenResolution - RBAC Token Replacement (Pipeline #19)

File: src/hooks/handle-token-resolution.ts Purpose: Processes JSON API responses to replace secure placeholders with actual data based on user permissions.

Performance & Security:

  • Status-Gated: Only processes successful (2xx) responses.
  • Size-Gated: Skips payloads > 5MB to prevent memory spikes.
  • Internal Bypass: Requests with X-Svelty-Internal headers bypass processing for zero-latency internal communication.
  • RBAC-Aware: Resolves tokens using the locals.user and locals.roles context.
  • Selective Processing: Only processes JSON responses from the /api namespace.

applyAllSecurityHeaders - Defense in Depth (Inlined into handleTurboPipeline) {#10-applyallsecurityheaders-defense-in-depth}

File: src/hooks/handle-security-headers.ts Purpose: Exports applyAllSecurityHeaders() β€” called within handleTurboPipeline (pipeline #2) and the global security guard in hooks.server.ts. No longer a standalone pipeline hook.

2026-06-05: Inlined into handleTurboPipeline. The applyAllSecurityHeaders() function is still called from two places: (1) handleTurboPipeline for normal pipeline responses, and (2) the top-level try/catch wrapper in hooks.server.ts for error responses (ensures security headers on 302, 404, and 500 responses even when hooks throw).

Hardening Features (applied via applyAllSecurityHeaders):

  • Clickjacking Protection: Enforces SAMEORIGIN policy via X-Frame-Options.
  • MIME Sniffing Prevention: Sets X-Content-Type-Options: nosniff.
  • Privacy Control: Configures Referrer-Policy to strict-origin-when-cross-origin.
  • HSTS Enforcement: Forces HTTPS in production for 1 year (including subdomains).
  • XSS Protection: Sets X-XSS-Protection: 1; mode=block.
  • DNS Control: Sets X-DNS-Prefetch-Control: off.
  • Permissions-Policy: Restricts access to geolocation, camera, microphone, etc.

Standard Middleware (Always Active)

handleApiRequests - API Authorization, Caching & ETags

File: src/hooks/handle-api-requests.ts Purpose: Authorizes API requests and intelligently caches GET responses with ETag-based conditional revalidation.

Core Responsibilities:

  • Role-Based Authorization: Validates the user’s role against the requested API endpoint via hasApiPermission().
  • ETag-Based Conditional Caching: Generates SHA1 ETags from response bodies. Returns 304 Not Modified when if-none-match matches.
  • 2-Layer Cache (L1 + L2): Memory-hot cache hits return instantly; misses are background-populated.
  • SWR (Stale-While-Revalidate) Pre-warming: After mutations, asynchronously fetches the updated resource to pre-warm the cache.
  • apiData Fast Path: If the handler stores response data in locals.apiData, skips response.clone() entirely (<10Β΅s vs ~200Β΅s).
  • Automatic Invalidation: Clears user-scoped cache entries on POST/PUT/DELETE/PATCH.

X-Cache Header Values:

Value Meaning
HIT Served from cache (L1 or L2)
MISS Cache miss, populated in background
NOCACHE ?nocache=true bypassed cache
REFRESH ?refresh=true forced revalidation
BYPASS GraphQL queries bypassing ETag (own cache layer via +server.ts)

ETag Implementation:

// SHA1 ETag (hardware-accelerated on modern CPUs)
etag = `"${crypto.createHash("sha1").update(responseBody).digest("hex").substring(0, 16)}"`;

// Conditional request: return 304 if unchanged
if (request.headers.get("if-none-match") === etag) {
  return new Response(null, { status: 304, headers: { etag } });
}

Key Features:

  • apiData Fast Path: Avoids response.clone() overhead when handlers pre-store data
  • SWR Pre-warming: Background cache refresh after mutations (disabled in BENCHMARK=true)
  • GraphQL Bypass: GraphQL uses its own HTTP-level response cache in +server.ts (cache-by-hash, 30s TTL). handleApiRequests delegates to it rather than double-caching.
  • Tenant-Scoped Keys: Cache keys include tenantId for multi-tenant isolation



Middleware Pipeline

sequenceDiagram
    participant Client
    participant Guard as hooks.server.ts (Global Guard)
    participant Turbo as 1. handleTurboPipeline
    participant Test as 2. handleTestIsolation
    participant Security as 3. handleSecurity
    participant RateLimit as 4. handleRateLimit
    participant State as 5. handleSystemState
    participant TurboGet as 6. handleTurboGet
    participant Redirects as 7. handleRedirects
    participant Comp as 9. handleCompression
    participant Auth as 12. handleAuthentication
    participant Authz as 13. handleAuthorization
    participant API as 17. handleApiRequests
    participant Endpoint

    Client->>Guard: Request
    Guard->>Hyper: resolve(event)
    Hyper->>Turbo: resolve(event)
    Note over Turbo: classifyRequest() + static fast-exit + setup gate
    alt Static / favicon / health fast-return
        Turbo-->>Guard: Response + security headers
    else Full pipeline
        Turbo->>Test: resolve(event)
        Test->>Security: resolve(event)
        alt Threat / honeypot
            Security-->>Guard: 403 / 200 decoy + IP flagged
        else Safe
            Security->>RateLimit: resolve(event)
            RateLimit->>State: resolve(event)
            State->>TurboGet: resolve(event)
            alt Turbo GET cache HIT
                TurboGet-->>Guard: Pre-compressed JSON + headers
            else Cache MISS
                TurboGet->>Redirects: resolve(event)
                Redirects->>Comp: resolve(event)
                Comp->>Auth: resolve(event)
                Auth->>Authz: resolve(event)
                alt UNAUTHORIZED
                    Authz-->>Guard: 401/403 + headers
                else AUTHORIZED
                    Authz->>API: resolve(event)
                    API->>Endpoint: resolve(event)
                    Endpoint-->>Guard: Response (headers on all paths)
                end
            end
        end
    end
    Note over Guard: try/catch applies security headers on middleware errors

Best Practices for Creating New Hooks

When extending the middleware pipeline, follow these best practices to maintain system stability, performance, and consistency:

  1. Idempotency: Ensure your hook can run multiple times without causing side effects.
  2. Performance First: Avoid long-running operations. Use caching for expensive lookups.
  3. Early Exits: Design your hook to exit as early as possible for requests it doesn’t need to handle.
  4. Use event.locals: Pass data between hooks using event.locals instead of global state.
  5. Single Responsibility: Each hook should have a single, well-defined purpose.
  6. Error Handling: Wrap your logic in try...catch blocks and use the SvelteKit error helper for throwing errors.
  7. Logging: Use the logger service to log important information and errors.

Testing Strategy

The server hooks middleware employs a strategic testing approach that balances direct unit testing with integration coverage:

Direct Unit Tests (17 hook test files, 100+ tests)

Complex hooks with intricate state machines and security logic receive dedicated unit tests:

1. handleSystemState (26 tests)

File: tests/unit/hooks/system-state.test.ts

Why dedicated tests? Complex state machine with 5 states (IDLE, INITIALIZING, READY, DEGRADED, FAILED) and state-dependent route blocking logic.

Test Coverage:

  • βœ… READY state: All routes allowed
  • βœ… DEGRADED state: Routes allowed with service warnings in event.locals
  • βœ… IDLE state: Only setup/health/static routes allowed
  • βœ… INITIALIZING state: Essential routes only
  • βœ… FAILED state: Only health checks allowed
  • βœ… Route pattern matching for special paths
  • βœ… State transition logging and metrics
bun test tests/unit/hooks/system-state.test.ts  # 26 tests

2. handleSecurity + Defense-in-Depth (69+ tests)

Files: tests/unit/hooks/defense-in-depth.test.ts, tests/unit/hooks/authentication.test.ts, tests/unit/hooks/authorization.test.ts, tests/unit/hooks/adversarial.test.ts

Why dedicated tests? Security-critical pattern matching, cookie prefix hardening, setup gating, and 4-layer RBAC validation.

Test Coverage:

  • βœ… Cookie prefix security (__Host- / __Secure- RFC 6265bis)
  • βœ… Setup completion gating (403 on re-seed after setup)
  • βœ… Handler-level admin verification (settings, automation, media)
  • βœ… Fail-closed API dispatcher (ENDPOINT_PERMISSIONS)
  • βœ… CSRF bypass rules for API keys vs browser sessions
  • βœ… Adversarial path traversal and injection patterns
bun test tests/unit/hooks/defense-in-depth.test.ts tests/unit/hooks/authentication.test.ts tests/unit/hooks/authorization.test.ts

Indirect Integration Coverage (9 hooks)

Simpler hooks with straightforward logic are tested through integration and API tests:

Hook Tested Via Test Count Reasoning
handleAuthentication User API tests 47 Session validation tested via authenticated API calls
handleAuthorization Collections API tests 19 RBAC tested via role-specific endpoints
handleSecurity Firewall + security unit tests 27+ Rate limiting, payload scanning, GraphQL shield
handleSystemState System state unit tests 26 Full state machine (IDLE to READY to FAILED)

| handleUserPreferences | UI rendering & i18n tests | 5+ | Language + theme sync tested in SSR tests | | applyAllSecurityHeaders | tests/unit/hooks/security-headers.test.ts | 8+ | Header validation + CSP tests (live inlined function) |

| handleApiRequests | All API endpoint tests | 80+ | API authorization/caching tested across APIs | | handleTurboPipeline | Hooks performance benchmark | 1 | Full pipeline latency measured in benchmark |

Testing Philosophy

When to use direct unit tests:

  • Complex state machines with multiple branches
  • Security-critical pattern matching
  • Intricate business logic requiring edge case validation
  • Performance-sensitive code needing microbenchmarks

When to rely on integration tests:

  • Simple transformations (header setting, cookie reading)
  • Middleware with straightforward pass-through logic
  • Hooks that primarily compose other services
  • Functionality already validated in downstream tests

Running All Hook Tests

# Run all direct hook tests
bun test tests/bun/hooks/

# Run hook tests + related integration tests
bun test tests/bun/hooks/ tests/bun/api/ tests/bun/services/

# Full test suite
bun test

Utility Exports

Utility functions exported from hooks.server.ts:

// Health metrics
//   getHealthMetrics() β†’ metricsService.getReport()
//
// Turbo-auth revocation (per-user invalidation after role/block/delete mutations)
//   invalidateTurboAuthForUser(userId)

Session-cache helpers (invalidateSessionCache, primeSessionMemoryCache) are imported directly from src/hooks/handle-authentication.ts by the auth handler and Auth service β€” they are not re-exported from hooks.server.ts.


Summary

The middleware architecture provides:

  • Security: Multi-layered defense with tenant isolation and automated threat detection
  • Performance: WeakRef caching, streaming responses, conditional loading
  • Observability: Unified MetricsService with comprehensive logging
  • Scalability: Clustered support with distributed caching and rate limiting

Related Documentation

architecturehooksmiddlewareperformancesecuritystate-managementlane-router
Was this page helpful?