Properties
category: reference
tags: [task, performance, cdn, architecture]
last_updated: 2026-03-14
confidence: high

E-2 Alternative: Client-Side Assembly

Status: Planned (alternative to Tasks/E-2_CDN_Read_Path) Depends on: Phase 2 complete, Dev/E-1_Cold_Start_Benchmarks, Design/CDN_Read_Path Branch: feat/E-2-cdn-read-path from phase-2

Premise

Otterwiki already requires JavaScript (halfmoon UI framework, dark mode toggle, MathJax, Mermaid). Since JS is a hard dependency, client-side fragment assembly is viable — and eliminates the assembly Lambda entirely.

Architecture

Write path (unchanged):
  MCP/API → API Gateway → Otterwiki Lambda (VPC+EFS) → git repo
                                                      → S3 fragments (on page save)

Read path (new):
  Browser → CloudFront → S3 (static shell HTML)
                       → JS fetches sidebar + content fragments from same CDN
                       → Assembles in DOM

No Lambda on the read path. Zero compute for reads.

How It Works

1. Shell HTML (static, cached indefinitely)

One HTML file per wiki, served as the CloudFront default. Contains:

  • Full <head>: CSS links, meta tags, site name, favicon
  • Page chrome: header/nav bar, search form, footer
  • Empty containers: <div id="sidebar"></div>, <div id="content"></div>, <div id="toc"></div>
  • Inline <script> (~30 lines) that:
    1. Reads the page path from window.location.pathname
    2. Fetches sidebar + content fragments in parallel from the CDN
    3. Inserts into the DOM containers
    4. Applies sidebar current-page highlighting
    5. Conditionally loads MathJax/Mermaid based on data-* attributes in the content fragment
  • Dark mode: JS reads halfmoon_preferredMode cookie and sets <body> class before fragments load (same as otterwiki does now, but inline instead of server-rendered)

The shell is regenerated only when wiki settings change (site name, logo, theme) or on deploy. Content-hashed filename for cache-busting.

2. Content fragment (per page, cached at CDN)

s3://bucket/fragments/{user_id}/{wiki_slug}/pages/{page_path}.html

Contains:

  • Rendered markdown HTML (from OtterwikiRenderer.markdown())
  • data-needs-mathjax / data-needs-mermaid attributes on the root element (from library_requirements)
  • TOC HTML in a <template id="page-toc"> element (from renderer.markdown() second return value)
  • Breadcrumb HTML

Updated on every page save. Cache-Control: public, max-age=31536000 (immutable — freshness managed by assembly script fetching latest version).

3. Sidebar fragment (per wiki, cached at CDN)

s3://bucket/fragments/{user_id}/{wiki_slug}/sidebar.html

Contains:

  • Page tree HTML (from SidebarPageIndex data, rendered without focus-mode highlighting)
  • Sidebar shortcuts (Home, A-Z, Changelog — READ-level only)

Updated on page create, delete, rename. NOT updated on content-only edits.

Content flash mitigation

The gap between shell render and fragment insertion is the time for two parallel CDN fetches (~10-50ms on cache hit). Three approaches, in order of preference:

Approach A: Inline critical content in the shell URL (recommended)

Use the URL path to generate a minimal loading state. The inline script immediately sets the document title from the path and shows a lightweight CSS skeleton in the content area:

<style>
  #content:empty::before {
    content: '';
    display: block;
    height: 1.2em;
    width: 60%;
    background: var(--content-skeleton-color, #e0e0e0);
    border-radius: 4px;
    animation: pulse 1s infinite;
  }
</style>

The skeleton is visible for <50ms on CDN cache hits — imperceptible on fast connections, graceful on slow ones.

Approach B: Service Worker pre-fetch

A service worker intercepts navigation requests and pre-fetches the content fragment for the target page. By the time the shell renders, the fragment is already in the browser cache. Adds complexity but eliminates the flash entirely after first visit.

Approach C: Embed above-the-fold content in shell

For the wiki home page (most common landing page), inline the content fragment directly in the shell HTML. Other pages use async fetch. Reduces flash for the most common entry point.

Decision: Approach A. CSS skeleton is simple, lightweight, and handles the common case. Service worker is overkill for MVP.

Implementation Plan

Wave 1: Fragment Generation (same as Option A)

Task E-2a: Write-time fragment renderer

Identical to the assembly Lambda plan — the fragment generation is the same regardless of how fragments are assembled. See Tasks/E-2_CDN_Read_Path Wave 1 for details.

Files:

  • app/cdn/fragment_renderer.py — render content, sidebar, shell fragments
  • app/cdn/s3_publisher.py — upload to S3
  • Integration via otterwiki page_saved plugin hook

One addition: render_shell_template() generates a complete HTML page (not a template with placeholders) containing the inline assembly script.

Tests: ~15 unit tests.

Wave 2: Infrastructure

Task E-2b: S3 + CloudFront

No assembly Lambda needed. Infrastructure is simpler:

  1. S3 bucket for fragments (or reuse existing lambda-code-bucket)
  2. CloudFront distribution:
    • Default behavior: S3 origin (fragment bucket)
    • URL rewriting (CloudFront Function, viewer-request):
      • /{username}/{wiki}/fragments/{user_id}/{wiki}/shell.html
      • /{username}/{wiki}/{page}fragments/{user_id}/{wiki}/shell.html (same shell for all pages — JS reads path)
      • /fragments/* → passthrough to S3 (for JS fragment fetches)
    • Behavior for dynamic routes: /-/*, /static/*, /admin/*, /mcp/* → API Gateway origin
    • Cache policy: shell cached indefinitely (content-hashed); fragments cached 1 year (immutable keys or cache-busted by query param)
  3. DNS: *.wikibot.io → CloudFront

The CloudFront Function for URL rewriting is ~15 lines:

function handler(event) {
    var request = event.request;
    var uri = request.uri;
    // Dynamic routes → pass through to API Gateway origin
    if (uri.startsWith('/-/') || uri.startsWith('/static/') || 
        uri.startsWith('/admin/') || uri.startsWith('/mcp/')) {
        return request;
    }
    // Fragment fetches → pass through to S3
    if (uri.startsWith('/fragments/')) {
        return request;
    }
    // All other paths → serve the wiki shell
    // Extract username and wiki slug from path
    var parts = uri.split('/').filter(Boolean);
    if (parts.length >= 2) {
        request.uri = '/fragments/' + parts[0] + '/' + parts[1] + '/shell.html';
    }
    return request;
}

Note: The URL rewriting function maps username to user_id — this requires either a lookup (adds latency) or using username in the S3 path (simpler, but diverges from internal UUID convention). Decision: use username in the fragment S3 path. Fragments are a CDN cache layer, not the source of truth. Username changes (rare) trigger a fragment re-publish.

Tests: Integration test — upload fragments, hit CloudFront, verify HTML.

Wave 3: Auth (Private Wikis)

Task E-2c: CloudFront auth

Same options as the assembly Lambda plan. For MVP, the simplest approach:

  1. Public wikis: no auth, fragments served directly
  2. Private wikis: CloudFront Function checks for a signed cookie or JWT
    • If missing/invalid → redirect to login page (served by API Gateway)
    • If valid → serve fragments

CloudFront Functions support JWT validation with symmetric keys (HMAC-SHA256). We'd need a shared secret between the auth system and CloudFront Functions.

Alternative: Lambda@Edge for RS256 JWT validation (current platform JWT uses RS256). Adds ~5ms.

Decision: defer to implementation. Start with public wikis only for MVP. Private wiki CDN auth is Wave 3.

Wave 4: Migration + Cutover

Task E-2d: Backfill and cutover

Identical to assembly Lambda plan:

  1. Backfill script renders all existing pages to S3
  2. DNS cutover: *.wikibot.io → CloudFront
  3. Verify reads from CDN, writes still work

Comparison with Assembly Lambda Plan

Assembly Lambda (Tasks/E-2_CDN_Read_Path) Client-Side Assembly (this plan)
Read path compute Assembly Lambda (256MB, non-VPC) None — pure S3 + CDN
Cache miss latency ~40ms warm, ~1s cold ~10-50ms (S3 via CDN, no Lambda)
Cache hit latency ~10-50ms ~10-50ms
Content flash None (complete HTML) <50ms (CSS skeleton, imperceptible on fast connections)
HTTP requests per page 1 3 (shell + sidebar + content, HTTP/2 multiplexed)
SEO Full HTML in response Content not in initial HTML (requires JS)
JS dependency No (but otterwiki requires JS anyway) Yes
Infra complexity S3 + CloudFront + Assembly Lambda + IAM S3 + CloudFront only
Lambdas to manage 2 (otterwiki + assembly) 1 (otterwiki only)
Code complexity Assembly handler (~50 lines) + fragment renderer Inline JS (~30 lines) + fragment renderer
Failure modes Assembly Lambda errors, S3 read failures S3 read failures (fewer moving parts)
Estimated effort ~3 days ~2 days
Monthly cost ~$0 ~$0

When to prefer Assembly Lambda

  • SEO matters (public wikis indexed by search engines)
  • JS-free rendering is a requirement
  • Content flash is unacceptable

When to prefer Client-Side Assembly

  • Simpler infrastructure is valued
  • JS is already required (our case)
  • Fewer Lambdas to manage is preferred
  • Faster cache-miss latency matters

Recommendation

Client-side assembly is simpler, has fewer moving parts, and performs as well or better than the assembly Lambda for our use case. The content flash is mitigated by CSS skeleton and fast CDN fetches. SEO is not a current concern (private wikis, MCP-primary access pattern).

If SEO becomes important for public wikis later, the assembly Lambda can be added as an additional origin behind the same CloudFront distribution — the fragment generation (Wave 1) is identical in both plans.