Blame

b03682 Claude (MCP) 2026-03-14 18:17:26
[mcp] [mcp] E-2 alternative: client-side assembly plan
1
---
2
category: reference
3
tags: [task, performance, cdn, architecture]
4
last_updated: 2026-03-14
5
confidence: high
6
---
7
8
# E-2 Alternative: Client-Side Assembly
9
10
**Status:** Planned (alternative to [[Tasks/E-2_CDN_Read_Path]])
11
**Depends on:** Phase 2 complete, [[Dev/E-1_Cold_Start_Benchmarks]], [[Design/CDN_Read_Path]]
12
**Branch:** `feat/E-2-cdn-read-path` from `phase-2`
13
14
## Premise
15
16
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.
17
18
## Architecture
19
20
```
21
Write path (unchanged):
22
MCP/API → API Gateway → Otterwiki Lambda (VPC+EFS) → git repo
23
→ S3 fragments (on page save)
24
25
Read path (new):
26
Browser → CloudFront → S3 (static shell HTML)
27
→ JS fetches sidebar + content fragments from same CDN
28
→ Assembles in DOM
29
```
30
31
No Lambda on the read path. Zero compute for reads.
32
33
## How It Works
34
35
### 1. Shell HTML (static, cached indefinitely)
36
37
One HTML file per wiki, served as the CloudFront default. Contains:
38
39
- Full `<head>`: CSS links, meta tags, site name, favicon
40
- Page chrome: header/nav bar, search form, footer
41
- Empty containers: `<div id="sidebar"></div>`, `<div id="content"></div>`, `<div id="toc"></div>`
42
- Inline `<script>` (~30 lines) that:
43
1. Reads the page path from `window.location.pathname`
44
2. Fetches sidebar + content fragments in parallel from the CDN
45
3. Inserts into the DOM containers
46
4. Applies sidebar current-page highlighting
47
5. Conditionally loads MathJax/Mermaid based on `data-*` attributes in the content fragment
48
- 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)
49
50
The shell is regenerated only when wiki settings change (site name, logo, theme) or on deploy. Content-hashed filename for cache-busting.
51
52
### 2. Content fragment (per page, cached at CDN)
53
54
`s3://bucket/fragments/{user_id}/{wiki_slug}/pages/{page_path}.html`
55
56
Contains:
57
- Rendered markdown HTML (from `OtterwikiRenderer.markdown()`)
58
- `data-needs-mathjax` / `data-needs-mermaid` attributes on the root element (from `library_requirements`)
59
- TOC HTML in a `<template id="page-toc">` element (from `renderer.markdown()` second return value)
60
- Breadcrumb HTML
61
62
Updated on every page save. `Cache-Control: public, max-age=31536000` (immutable — freshness managed by assembly script fetching latest version).
63
64
### 3. Sidebar fragment (per wiki, cached at CDN)
65
66
`s3://bucket/fragments/{user_id}/{wiki_slug}/sidebar.html`
67
68
Contains:
69
- Page tree HTML (from `SidebarPageIndex` data, rendered without focus-mode highlighting)
70
- Sidebar shortcuts (Home, A-Z, Changelog — READ-level only)
71
72
Updated on page create, delete, rename. NOT updated on content-only edits.
73
74
### Content flash mitigation
75
76
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:
77
78
**Approach A: Inline critical content in the shell URL (recommended)**
79
80
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:
81
82
```html
83
<style>
84
#content:empty::before {
85
content: '';
86
display: block;
87
height: 1.2em;
88
width: 60%;
89
background: var(--content-skeleton-color, #e0e0e0);
90
border-radius: 4px;
91
animation: pulse 1s infinite;
92
}
93
</style>
94
```
95
96
The skeleton is visible for <50ms on CDN cache hits — imperceptible on fast connections, graceful on slow ones.
97
98
**Approach B: Service Worker pre-fetch**
99
100
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.
101
102
**Approach C: Embed above-the-fold content in shell**
103
104
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.
105
106
**Decision: Approach A.** CSS skeleton is simple, lightweight, and handles the common case. Service worker is overkill for MVP.
107
108
## Implementation Plan
109
110
### Wave 1: Fragment Generation (same as Option A)
111
112
**Task E-2a: Write-time fragment renderer**
113
114
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.
115
116
Files:
117
- `app/cdn/fragment_renderer.py` — render content, sidebar, shell fragments
118
- `app/cdn/s3_publisher.py` — upload to S3
119
- Integration via otterwiki `page_saved` plugin hook
120
121
One addition: `render_shell_template()` generates a complete HTML page (not a template with placeholders) containing the inline assembly script.
122
123
**Tests:** ~15 unit tests.
124
125
### Wave 2: Infrastructure
126
127
**Task E-2b: S3 + CloudFront**
128
129
No assembly Lambda needed. Infrastructure is simpler:
130
131
1. S3 bucket for fragments (or reuse existing `lambda-code-bucket`)
132
2. CloudFront distribution:
133
- Default behavior: S3 origin (fragment bucket)
134
- URL rewriting (CloudFront Function, viewer-request):
135
- `/{username}/{wiki}/``fragments/{user_id}/{wiki}/shell.html`
136
- `/{username}/{wiki}/{page}``fragments/{user_id}/{wiki}/shell.html` (same shell for all pages — JS reads path)
137
- `/fragments/*` → passthrough to S3 (for JS fragment fetches)
138
- Behavior for dynamic routes: `/-/*`, `/static/*`, `/admin/*`, `/mcp/*` → API Gateway origin
139
- Cache policy: shell cached indefinitely (content-hashed); fragments cached 1 year (immutable keys or cache-busted by query param)
140
3. DNS: `*.wikibot.io` → CloudFront
141
142
The CloudFront Function for URL rewriting is ~15 lines:
143
```javascript
144
function handler(event) {
145
var request = event.request;
146
var uri = request.uri;
147
// Dynamic routes → pass through to API Gateway origin
148
if (uri.startsWith('/-/') || uri.startsWith('/static/') ||
149
uri.startsWith('/admin/') || uri.startsWith('/mcp/')) {
150
return request;
151
}
152
// Fragment fetches → pass through to S3
153
if (uri.startsWith('/fragments/')) {
154
return request;
155
}
156
// All other paths → serve the wiki shell
157
// Extract username and wiki slug from path
158
var parts = uri.split('/').filter(Boolean);
159
if (parts.length >= 2) {
160
request.uri = '/fragments/' + parts[0] + '/' + parts[1] + '/shell.html';
161
}
162
return request;
163
}
164
```
165
166
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.
167
168
**Tests:** Integration test — upload fragments, hit CloudFront, verify HTML.
169
170
### Wave 3: Auth (Private Wikis)
171
172
**Task E-2c: CloudFront auth**
173
174
Same options as the assembly Lambda plan. For MVP, the simplest approach:
175
176
1. Public wikis: no auth, fragments served directly
177
2. Private wikis: CloudFront Function checks for a signed cookie or JWT
178
- If missing/invalid → redirect to login page (served by API Gateway)
179
- If valid → serve fragments
180
181
CloudFront Functions support JWT validation with symmetric keys (HMAC-SHA256). We'd need a shared secret between the auth system and CloudFront Functions.
182
183
Alternative: Lambda@Edge for RS256 JWT validation (current platform JWT uses RS256). Adds ~5ms.
184
185
**Decision: defer to implementation. Start with public wikis only for MVP. Private wiki CDN auth is Wave 3.**
186
187
### Wave 4: Migration + Cutover
188
189
**Task E-2d: Backfill and cutover**
190
191
Identical to assembly Lambda plan:
192
1. Backfill script renders all existing pages to S3
193
2. DNS cutover: `*.wikibot.io` → CloudFront
194
3. Verify reads from CDN, writes still work
195
196
## Comparison with Assembly Lambda Plan
197
198
| | Assembly Lambda ([[Tasks/E-2_CDN_Read_Path]]) | Client-Side Assembly (this plan) |
199
|---|---|---|
200
| **Read path compute** | Assembly Lambda (256MB, non-VPC) | None — pure S3 + CDN |
201
| **Cache miss latency** | ~40ms warm, ~1s cold | ~10-50ms (S3 via CDN, no Lambda) |
202
| **Cache hit latency** | ~10-50ms | ~10-50ms |
203
| **Content flash** | None (complete HTML) | <50ms (CSS skeleton, imperceptible on fast connections) |
204
| **HTTP requests per page** | 1 | 3 (shell + sidebar + content, HTTP/2 multiplexed) |
205
| **SEO** | Full HTML in response | Content not in initial HTML (requires JS) |
206
| **JS dependency** | No (but otterwiki requires JS anyway) | Yes |
207
| **Infra complexity** | S3 + CloudFront + Assembly Lambda + IAM | S3 + CloudFront only |
208
| **Lambdas to manage** | 2 (otterwiki + assembly) | 1 (otterwiki only) |
209
| **Code complexity** | Assembly handler (~50 lines) + fragment renderer | Inline JS (~30 lines) + fragment renderer |
210
| **Failure modes** | Assembly Lambda errors, S3 read failures | S3 read failures (fewer moving parts) |
211
| **Estimated effort** | ~3 days | ~2 days |
212
| **Monthly cost** | ~$0 | ~$0 |
213
214
### When to prefer Assembly Lambda
215
- SEO matters (public wikis indexed by search engines)
216
- JS-free rendering is a requirement
217
- Content flash is unacceptable
218
219
### When to prefer Client-Side Assembly
220
- Simpler infrastructure is valued
221
- JS is already required (our case)
222
- Fewer Lambdas to manage is preferred
223
- Faster cache-miss latency matters
224
508b74 Claude (MCP) 2026-03-14 18:22:07
[mcp] [mcp] E-2 client-side: add SEO strategy (meta tags, bot routing, sitemap)
225
## SEO Strategy (Future: Public Wikis)
226
227
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:
228
229
### 1. Page-specific meta tags (low effort, high value)
230
231
On page save, extract the first ~160 characters of plain text from the rendered content. Store alongside the content fragment as a metadata file:
232
233
`s3://bucket/fragments/{username}/{wiki}/meta/{page_path}.json`
234
```json
235
{
236
"title": "Page Title",
237
"description": "First 160 chars of page content...",
238
"og_image": null
239
}
240
```
241
242
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.
243
244
### 2. Dynamic rendering for bots (medium effort, full coverage)
245
246
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`:
247
248
```javascript
249
var botPattern = /googlebot|bingbot|slurp|duckduckbot|baiduspider|yandexbot|facebot|twitterbot|linkedinbot/i;
250
if (botPattern.test(request.headers['user-agent'].value)) {
251
// Route to assembly Lambda origin — returns complete HTML
252
request.origin = { /* assembly Lambda origin config */ };
253
}
254
// Humans get client-side assembly (default S3 origin)
255
```
256
257
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).
258
259
Since the fragment generation (Wave 1) is identical in both plans, adding the assembly Lambda later is a bolt-on — not a rearchitecture.
260
261
### 3. Sitemap generation (low effort, aids discovery)
262
263
On page create/delete/rename, regenerate `sitemap.xml` for public wikis and upload to S3:
264
265
`s3://bucket/fragments/{username}/{wiki}/sitemap.xml`
266
267
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.
268
269
### Phasing
270
271
- **Now (MVP):** No SEO support needed. All wikis are private.
272
- **When public wikis ship:** Add meta tags (item 1) and sitemap (item 3). Covers link previews and search discovery. ~0.5 day effort.
273
- **If search ranking matters:** Add bot-routed assembly Lambda (item 2). ~1 day effort on top of the existing fragment infrastructure.
274
b03682 Claude (MCP) 2026-03-14 18:17:26
[mcp] [mcp] E-2 alternative: client-side assembly plan
275
## Recommendation
276
508b74 Claude (MCP) 2026-03-14 18:22:07
[mcp] [mcp] E-2 client-side: add SEO strategy (meta tags, bot routing, sitemap)
277
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.
278
279
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.
b03682 Claude (MCP) 2026-03-14 18:17:26
[mcp] [mcp] E-2 alternative: client-side assembly plan
280
508b74 Claude (MCP) 2026-03-14 18:22:07
[mcp] [mcp] E-2 client-side: add SEO strategy (meta tags, bot routing, sitemap)
281
Start with client-side assembly. Add SEO layers when public wikis ship.