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:- Reads the page path from
window.location.pathname - Fetches sidebar + content fragments in parallel from the CDN
- Inserts into the DOM containers
- Applies sidebar current-page highlighting
- Conditionally loads MathJax/Mermaid based on
data-*attributes in the content fragment
- Reads the page path from
- Dark mode: JS reads
halfmoon_preferredModecookie 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-mermaidattributes on the root element (fromlibrary_requirements)- TOC HTML in a
<template id="page-toc">element (fromrenderer.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
SidebarPageIndexdata, 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 fragmentsapp/cdn/s3_publisher.py— upload to S3- Integration via otterwiki
page_savedplugin 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:
- S3 bucket for fragments (or reuse existing
lambda-code-bucket) - 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)
- 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:
- Public wikis: no auth, fragments served directly
- 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:
- Backfill script renders all existing pages to S3
- DNS cutover:
*.wikibot.io→ CloudFront - 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
SEO Strategy (Future: Public Wikis)
Client-side assembly means page content is not in the initial HTML — a problem for search engines and link previews. This is irrelevant for private wikis (our current state) but matters when public wikis exist. Three layered mitigations:
1. Page-specific meta tags (low effort, high value)
On page save, extract the first ~160 characters of plain text from the rendered content. Store alongside the content fragment as a metadata file:
s3://bucket/fragments/{username}/{wiki}/meta/{page_path}.json
{ "title": "Page Title", "description": "First 160 chars of page content...", "og_image": null }
The CloudFront Function injects these as <meta> tags when rewriting the shell URL — or the shell JS sets them before fragment fetch. This handles link previews (Slack, Twitter, Discord, iMessage) without any server-side rendering, since unfurlers read <meta> tags from the initial HTML without executing JS.
2. Dynamic rendering for bots (medium effort, full coverage)
Add the assembly Lambda (from Tasks/E-2_CDN_Read_Path) as a second origin behind the same CloudFront distribution. A CloudFront Function on viewer-request checks User-Agent:
var botPattern = /googlebot|bingbot|slurp|duckduckbot|baiduspider|yandexbot|facebot|twitterbot|linkedinbot/i; if (botPattern.test(request.headers['user-agent'].value)) { // Route to assembly Lambda origin — returns complete HTML request.origin = { /* assembly Lambda origin config */ }; } // Humans get client-side assembly (default S3 origin)
This is Google's recommended approach for JS-heavy sites. Bots get full HTML from the assembly Lambda. Humans get the fast client-side path. The assembly Lambda can be cold (nobody cares if Googlebot waits 1s).
Since the fragment generation (Wave 1) is identical in both plans, adding the assembly Lambda later is a bolt-on — not a rearchitecture.
3. Sitemap generation (low effort, aids discovery)
On page create/delete/rename, regenerate sitemap.xml for public wikis and upload to S3:
s3://bucket/fragments/{username}/{wiki}/sitemap.xml
Serve via CloudFront at /{username}/{wiki}/sitemap.xml. Helps search engines discover pages regardless of rendering method. Submit to Google Search Console for faster indexing.
Phasing
- Now (MVP): No SEO support needed. All wikis are private.
- When public wikis ship: Add meta tags (item 1) and sitemap (item 3). Covers link previews and search discovery. ~0.5 day effort.
- If search ranking matters: Add bot-routed assembly Lambda (item 2). ~1 day effort on top of the existing fragment infrastructure.
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.
The SEO gap is addressed in layers: meta tags for link previews (cheap), bot routing to the assembly Lambda for full indexing (bolt-on later). Fragment generation is shared between both paths, so the assembly Lambda is an additive enhancement — not a prerequisite or a rearchitecture.
Start with client-side assembly. Add SEO layers when public wikis ship.