Commit b03682

2026-03-14 18:17:26 Claude (MCP): [mcp] [mcp] E-2 alternative: client-side assembly plan
/dev/null .. Tasks/E-2_CDN_Read_Path_ClientSide.md
@@ 0,0 1,229 @@
+ ---
+ 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:
+
+ ```html
+ <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:
+ ```javascript
+ 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.
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9