@affectively/aeon-pages 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +625 -0
- package/examples/basic/aeon.config.ts +39 -0
- package/examples/basic/components/Cursor.tsx +86 -0
- package/examples/basic/components/OfflineIndicator.tsx +103 -0
- package/examples/basic/components/PresenceBar.tsx +77 -0
- package/examples/basic/package.json +20 -0
- package/examples/basic/pages/index.tsx +80 -0
- package/package.json +101 -0
- package/packages/analytics/README.md +309 -0
- package/packages/analytics/build.ts +35 -0
- package/packages/analytics/package.json +50 -0
- package/packages/analytics/src/click-tracker.ts +368 -0
- package/packages/analytics/src/context-bridge.ts +319 -0
- package/packages/analytics/src/data-layer.ts +302 -0
- package/packages/analytics/src/gtm-loader.ts +239 -0
- package/packages/analytics/src/index.ts +230 -0
- package/packages/analytics/src/merkle-tree.ts +489 -0
- package/packages/analytics/src/provider.tsx +300 -0
- package/packages/analytics/src/types.ts +320 -0
- package/packages/analytics/src/use-analytics.ts +296 -0
- package/packages/analytics/tsconfig.json +19 -0
- package/packages/benchmarks/src/benchmark.test.ts +691 -0
- package/packages/cli/dist/index.js +61899 -0
- package/packages/cli/package.json +43 -0
- package/packages/cli/src/commands/build.test.ts +682 -0
- package/packages/cli/src/commands/build.ts +890 -0
- package/packages/cli/src/commands/dev.ts +473 -0
- package/packages/cli/src/commands/init.ts +409 -0
- package/packages/cli/src/commands/start.ts +297 -0
- package/packages/cli/src/index.ts +105 -0
- package/packages/directives/src/use-aeon.ts +272 -0
- package/packages/mcp-server/package.json +51 -0
- package/packages/mcp-server/src/index.ts +178 -0
- package/packages/mcp-server/src/resources.ts +346 -0
- package/packages/mcp-server/src/tools/index.ts +36 -0
- package/packages/mcp-server/src/tools/navigation.ts +545 -0
- package/packages/mcp-server/tsconfig.json +21 -0
- package/packages/react/package.json +40 -0
- package/packages/react/src/Link.tsx +388 -0
- package/packages/react/src/components/InstallPrompt.tsx +286 -0
- package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
- package/packages/react/src/components/PushNotifications.tsx +453 -0
- package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
- package/packages/react/src/hooks/useConflicts.ts +277 -0
- package/packages/react/src/hooks/useNetworkState.ts +209 -0
- package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
- package/packages/react/src/hooks/useServiceWorker.ts +278 -0
- package/packages/react/src/hooks.ts +195 -0
- package/packages/react/src/index.ts +151 -0
- package/packages/react/src/provider.tsx +467 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/runtime/README.md +399 -0
- package/packages/runtime/build.ts +48 -0
- package/packages/runtime/package.json +71 -0
- package/packages/runtime/schema.sql +40 -0
- package/packages/runtime/src/api-routes.ts +465 -0
- package/packages/runtime/src/benchmark.ts +171 -0
- package/packages/runtime/src/cache.ts +479 -0
- package/packages/runtime/src/durable-object.ts +1341 -0
- package/packages/runtime/src/index.ts +360 -0
- package/packages/runtime/src/navigation.test.ts +421 -0
- package/packages/runtime/src/navigation.ts +422 -0
- package/packages/runtime/src/nextjs-adapter.ts +272 -0
- package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
- package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
- package/packages/runtime/src/offline/encryption.test.ts +412 -0
- package/packages/runtime/src/offline/encryption.ts +397 -0
- package/packages/runtime/src/offline/types.ts +465 -0
- package/packages/runtime/src/predictor.ts +371 -0
- package/packages/runtime/src/registry.ts +351 -0
- package/packages/runtime/src/router/context-extractor.ts +661 -0
- package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
- package/packages/runtime/src/router/esi-control.ts +541 -0
- package/packages/runtime/src/router/esi-cyrano.ts +779 -0
- package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
- package/packages/runtime/src/router/esi-react.tsx +1065 -0
- package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
- package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
- package/packages/runtime/src/router/esi-translate.ts +503 -0
- package/packages/runtime/src/router/esi.ts +666 -0
- package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
- package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
- package/packages/runtime/src/router/index.ts +298 -0
- package/packages/runtime/src/router/merkle-capability.ts +473 -0
- package/packages/runtime/src/router/speculation.ts +451 -0
- package/packages/runtime/src/router/types.ts +630 -0
- package/packages/runtime/src/router.test.ts +470 -0
- package/packages/runtime/src/router.ts +302 -0
- package/packages/runtime/src/server.ts +481 -0
- package/packages/runtime/src/service-worker-push.ts +319 -0
- package/packages/runtime/src/service-worker.ts +553 -0
- package/packages/runtime/src/skeleton-hydrate.ts +237 -0
- package/packages/runtime/src/speculation.test.ts +389 -0
- package/packages/runtime/src/speculation.ts +486 -0
- package/packages/runtime/src/storage.test.ts +1297 -0
- package/packages/runtime/src/storage.ts +1048 -0
- package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
- package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
- package/packages/runtime/src/sync/coordinator.test.ts +608 -0
- package/packages/runtime/src/sync/coordinator.ts +596 -0
- package/packages/runtime/src/tree-compiler.ts +295 -0
- package/packages/runtime/src/types.ts +728 -0
- package/packages/runtime/src/worker.ts +327 -0
- package/packages/runtime/tsconfig.json +20 -0
- package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
- package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
- package/packages/runtime/wasm/package.json +21 -0
- package/packages/runtime/wrangler.toml +41 -0
- package/packages/runtime-wasm/Cargo.lock +436 -0
- package/packages/runtime-wasm/Cargo.toml +29 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
- package/packages/runtime-wasm/pkg/package.json +21 -0
- package/packages/runtime-wasm/src/hydrate.rs +352 -0
- package/packages/runtime-wasm/src/lib.rs +191 -0
- package/packages/runtime-wasm/src/render.rs +629 -0
- package/packages/runtime-wasm/src/router.rs +298 -0
- package/packages/runtime-wasm/src/skeleton.rs +430 -0
- package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
# RFC-001: Zero-Dependency Page Rendering
|
|
2
|
+
|
|
3
|
+
**Status**: In Progress
|
|
4
|
+
**Author**: AFFECTIVELY Engineering
|
|
5
|
+
**Created**: 2026-02-06
|
|
6
|
+
**Target**: aeon-pages v2.0.0
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
Implement a rendering pipeline that produces **completely self-contained HTML documents** requiring zero external requests. All CSS, images, fonts, and critical JS are inlined as data URIs or embedded directly. The WASM-based AST parser walks the DOM faster than browsers, computing styles and resolving assets at render time.
|
|
13
|
+
|
|
14
|
+
## Motivation
|
|
15
|
+
|
|
16
|
+
### Current Performance Profile
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Page Load Timeline (Current):
|
|
20
|
+
├── HTML Document 50ms
|
|
21
|
+
├── → /styles.css (105KB) 150ms [blocking]
|
|
22
|
+
├── → /client.css (46KB) 100ms [blocking]
|
|
23
|
+
├── → /fonts/inter.woff2 (50KB) 120ms
|
|
24
|
+
├── → /images/logo.svg (5KB) 80ms
|
|
25
|
+
├── → /client.js (200KB) 180ms
|
|
26
|
+
└── → First Paint ~500ms
|
|
27
|
+
→ Interactive ~1200ms
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Problems:**
|
|
31
|
+
1. Multiple round-trips to origin/CDN
|
|
32
|
+
2. CSS blocks first paint
|
|
33
|
+
3. Font loading causes layout shift
|
|
34
|
+
4. JS bundle too large for edge delivery
|
|
35
|
+
5. Cache invalidation cascades
|
|
36
|
+
|
|
37
|
+
### Target Performance Profile
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Page Load Timeline (Target):
|
|
41
|
+
├── HTML Document (all-in-one) 80ms
|
|
42
|
+
└── → First Paint <100ms
|
|
43
|
+
→ Interactive <200ms
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Single request. Zero dependencies. Instant render.**
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Design
|
|
51
|
+
|
|
52
|
+
### The WASM Render Pipeline
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
56
|
+
│ BUILD TIME (Once per deploy) │
|
|
57
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
58
|
+
│ │
|
|
59
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │
|
|
60
|
+
│ │ Components │───▶│ AST Parser │───▶│ CSS Manifest │ │
|
|
61
|
+
│ │ (TSX files) │ │ (SWC) │ │ (class → rules mapping) │ │
|
|
62
|
+
│ └──────────────┘ └──────────────┘ └──────────────────────────────┘ │
|
|
63
|
+
│ │
|
|
64
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │
|
|
65
|
+
│ │ Assets │───▶│ Optimizer │───▶│ Asset Manifest │ │
|
|
66
|
+
│ │ (SVG/PNG/...)│ │ (SVGO/Sharp) │ │ (path → data URI mapping) │ │
|
|
67
|
+
│ └──────────────┘ └──────────────┘ └──────────────────────────────┘ │
|
|
68
|
+
│ │
|
|
69
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │
|
|
70
|
+
│ │ Fonts │───▶│ Subsetter │───▶│ Font Manifest │ │
|
|
71
|
+
│ │ (WOFF2) │ │ (fonttools) │ │ (family → base64 data URI) │ │
|
|
72
|
+
│ └──────────────┘ └──────────────┘ └──────────────────────────────┘ │
|
|
73
|
+
│ │
|
|
74
|
+
│ ▼ │
|
|
75
|
+
│ ┌────────────────────────┐ │
|
|
76
|
+
│ │ Unified Render Manifest │ ──▶ D1/KV │
|
|
77
|
+
│ └────────────────────────┘ │
|
|
78
|
+
│ │
|
|
79
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
80
|
+
|
|
81
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
82
|
+
│ RENDER TIME (Per request) │
|
|
83
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
84
|
+
│ │
|
|
85
|
+
│ ┌──────────────┐ ┌──────────────────────────────────────────────────┐ │
|
|
86
|
+
│ │ Page Session │───▶│ WASM Renderer │ │
|
|
87
|
+
│ │ (from D1) │ │ │ │
|
|
88
|
+
│ └──────────────┘ │ 1. Walk component tree (parallel) │ │
|
|
89
|
+
│ │ 2. Collect CSS classes → lookup in manifest │ │
|
|
90
|
+
│ ┌──────────────┐ │ 3. Resolve asset refs → inline data URIs │ │
|
|
91
|
+
│ │ Render │───▶│ 4. Apply inline styles to nodes │ │
|
|
92
|
+
│ │ Manifest │ │ 5. Embed fonts as @font-face data URIs │ │
|
|
93
|
+
│ └──────────────┘ │ 6. Generate minimal hydration script │ │
|
|
94
|
+
│ │ │ │
|
|
95
|
+
│ └────────────────────┬─────────────────────────────┘ │
|
|
96
|
+
│ │ │
|
|
97
|
+
│ ▼ │
|
|
98
|
+
│ ┌────────────────────────────────────────────────┐ │
|
|
99
|
+
│ │ Self-Contained HTML Document │ │
|
|
100
|
+
│ │ │ │
|
|
101
|
+
│ │ <!DOCTYPE html> │ │
|
|
102
|
+
│ │ <html> │ │
|
|
103
|
+
│ │ <head> │ │
|
|
104
|
+
│ │ <style>/* All CSS inline */</style> │ │
|
|
105
|
+
│ │ <style>@font-face { src: data:... }</style> │ │
|
|
106
|
+
│ │ </head> │ │
|
|
107
|
+
│ │ <body> │ │
|
|
108
|
+
│ │ <img src="data:image/svg+xml;base64,..."> │ │
|
|
109
|
+
│ │ <script>/* Minimal hydration */</script> │ │
|
|
110
|
+
│ │ </body> │ │
|
|
111
|
+
│ │ </html> │ │
|
|
112
|
+
│ │ │ │
|
|
113
|
+
│ └────────────────────────────────────────────────┘ │
|
|
114
|
+
│ │ │
|
|
115
|
+
│ ┌──────────────────────┴──────────────────────┐ │
|
|
116
|
+
│ ▼ ▼ ▼ │
|
|
117
|
+
│ Edge Cache KV Cache D1 Cache │
|
|
118
|
+
│ (1-5ms) (5-10ms) (10-20ms) │
|
|
119
|
+
│ │
|
|
120
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Detailed Design
|
|
126
|
+
|
|
127
|
+
### 1. WASM AST Renderer
|
|
128
|
+
|
|
129
|
+
The core of the system is a Rust/WASM module that walks the component tree and produces fully-resolved HTML.
|
|
130
|
+
|
|
131
|
+
```rust
|
|
132
|
+
// packages/runtime-wasm/src/renderer.rs
|
|
133
|
+
|
|
134
|
+
use wasm_bindgen::prelude::*;
|
|
135
|
+
use std::collections::{HashMap, HashSet};
|
|
136
|
+
|
|
137
|
+
#[wasm_bindgen]
|
|
138
|
+
pub struct AeonRenderer {
|
|
139
|
+
css_manifest: CSSManifest,
|
|
140
|
+
asset_manifest: AssetManifest,
|
|
141
|
+
font_manifest: FontManifest,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[wasm_bindgen]
|
|
145
|
+
impl AeonRenderer {
|
|
146
|
+
#[wasm_bindgen(constructor)]
|
|
147
|
+
pub fn new(
|
|
148
|
+
css_manifest: JsValue,
|
|
149
|
+
asset_manifest: JsValue,
|
|
150
|
+
font_manifest: JsValue,
|
|
151
|
+
) -> AeonRenderer {
|
|
152
|
+
AeonRenderer {
|
|
153
|
+
css_manifest: serde_wasm_bindgen::from_value(css_manifest).unwrap(),
|
|
154
|
+
asset_manifest: serde_wasm_bindgen::from_value(asset_manifest).unwrap(),
|
|
155
|
+
font_manifest: serde_wasm_bindgen::from_value(font_manifest).unwrap(),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[wasm_bindgen]
|
|
160
|
+
pub fn render(&self, component_tree: JsValue) -> JsValue {
|
|
161
|
+
let tree: ComponentNode = serde_wasm_bindgen::from_value(component_tree).unwrap();
|
|
162
|
+
|
|
163
|
+
let mut ctx = RenderContext::new();
|
|
164
|
+
|
|
165
|
+
// Walk tree, collecting all requirements
|
|
166
|
+
self.walk_tree(&tree, &mut ctx);
|
|
167
|
+
|
|
168
|
+
// Assemble final output
|
|
169
|
+
let output = RenderOutput {
|
|
170
|
+
html: ctx.html,
|
|
171
|
+
css: self.assemble_css(&ctx.classes),
|
|
172
|
+
fonts: self.assemble_fonts(&ctx.font_families),
|
|
173
|
+
critical_js: self.generate_hydration_script(&ctx.interactive_nodes),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
serde_wasm_bindgen::to_value(&output).unwrap()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn walk_tree(&self, node: &ComponentNode, ctx: &mut RenderContext) {
|
|
180
|
+
// Collect CSS classes
|
|
181
|
+
if let Some(class_name) = &node.class_name {
|
|
182
|
+
for class in class_name.split_whitespace() {
|
|
183
|
+
ctx.classes.insert(class.to_string());
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Resolve asset references
|
|
188
|
+
let resolved_props = self.resolve_assets(&node.props, ctx);
|
|
189
|
+
|
|
190
|
+
// Render this node
|
|
191
|
+
ctx.html.push_str(&self.render_node(node, &resolved_props));
|
|
192
|
+
|
|
193
|
+
// Recurse into children
|
|
194
|
+
for child in &node.children {
|
|
195
|
+
match child {
|
|
196
|
+
Child::Node(n) => self.walk_tree(n, ctx),
|
|
197
|
+
Child::Text(t) => ctx.html.push_str(&html_escape(t)),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Close tag
|
|
202
|
+
if !is_void_element(&node.tag) {
|
|
203
|
+
ctx.html.push_str(&format!("</{}>", node.tag));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
fn resolve_assets(&self, props: &Props, ctx: &mut RenderContext) -> Props {
|
|
208
|
+
let mut resolved = props.clone();
|
|
209
|
+
|
|
210
|
+
// Resolve src attributes (images)
|
|
211
|
+
if let Some(src) = resolved.get("src") {
|
|
212
|
+
if let Some(data_uri) = self.asset_manifest.get(src) {
|
|
213
|
+
resolved.insert("src".to_string(), data_uri.clone());
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Resolve href for stylesheets (shouldn't exist, but handle gracefully)
|
|
218
|
+
if let Some(href) = resolved.get("href") {
|
|
219
|
+
if href.ends_with(".css") {
|
|
220
|
+
// Remove external CSS reference
|
|
221
|
+
resolved.remove("href");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
resolved
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn assemble_css(&self, classes: &HashSet<String>) -> String {
|
|
229
|
+
let mut css = String::new();
|
|
230
|
+
let mut seen = HashSet::new();
|
|
231
|
+
|
|
232
|
+
// Sort for deterministic output
|
|
233
|
+
let mut sorted_classes: Vec<_> = classes.iter().collect();
|
|
234
|
+
sorted_classes.sort();
|
|
235
|
+
|
|
236
|
+
for class in sorted_classes {
|
|
237
|
+
if let Some(rules) = self.css_manifest.get(class) {
|
|
238
|
+
for rule in rules {
|
|
239
|
+
if !seen.contains(rule) {
|
|
240
|
+
css.push_str(rule);
|
|
241
|
+
css.push('\n');
|
|
242
|
+
seen.insert(rule.clone());
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
css
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fn assemble_fonts(&self, families: &HashSet<String>) -> String {
|
|
252
|
+
let mut css = String::new();
|
|
253
|
+
|
|
254
|
+
for family in families {
|
|
255
|
+
if let Some(font_data) = self.font_manifest.get(family) {
|
|
256
|
+
css.push_str(&format!(
|
|
257
|
+
r#"@font-face {{
|
|
258
|
+
font-family: '{}';
|
|
259
|
+
font-weight: {};
|
|
260
|
+
font-style: {};
|
|
261
|
+
font-display: swap;
|
|
262
|
+
src: url('data:font/woff2;base64,{}') format('woff2');
|
|
263
|
+
}}
|
|
264
|
+
"#,
|
|
265
|
+
font_data.family,
|
|
266
|
+
font_data.weight,
|
|
267
|
+
font_data.style,
|
|
268
|
+
font_data.base64
|
|
269
|
+
));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
css
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn generate_hydration_script(&self, nodes: &[InteractiveNode]) -> String {
|
|
277
|
+
if nodes.is_empty() {
|
|
278
|
+
return String::new();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Minimal script that lazy-loads component JS only when needed
|
|
282
|
+
format!(
|
|
283
|
+
r#"<script type="module">
|
|
284
|
+
const h=async(e)=>{{const c=e.dataset.aeonComponent;const m=await import('/_aeon/c/'+c+'.js');m.hydrate(e)}};
|
|
285
|
+
document.querySelectorAll('[data-aeon-i]').forEach(e=>{{
|
|
286
|
+
if(e.dataset.aeonHydrate==='eager')h(e);
|
|
287
|
+
else if('IntersectionObserver' in window){{
|
|
288
|
+
new IntersectionObserver((es,o)=>{{es.forEach(en=>{{if(en.isIntersecting){{o.unobserve(en.target);h(en.target)}}}})}}).observe(e)
|
|
289
|
+
}}else h(e)
|
|
290
|
+
}});
|
|
291
|
+
</script>"#
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### 2. CSS Manifest Generation (Build Time)
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
// packages/build/src/css-manifest.ts
|
|
301
|
+
|
|
302
|
+
interface CSSManifest {
|
|
303
|
+
version: string;
|
|
304
|
+
rules: Map<string, string[]>; // className → CSS rules
|
|
305
|
+
variants: Map<string, string>; // variant → media query
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function generateCSSManifest(config: AeonConfig): Promise<CSSManifest> {
|
|
309
|
+
// 1. Generate full Tailwind CSS
|
|
310
|
+
const fullCSS = await execTailwind(config.tailwindConfig);
|
|
311
|
+
|
|
312
|
+
// 2. Parse into AST
|
|
313
|
+
const ast = postcss.parse(fullCSS);
|
|
314
|
+
|
|
315
|
+
// 3. Build class → rules index
|
|
316
|
+
const rules = new Map<string, string[]>();
|
|
317
|
+
|
|
318
|
+
ast.walkRules((rule) => {
|
|
319
|
+
const classMatch = rule.selector.match(/^\.([a-zA-Z0-9_-]+)/);
|
|
320
|
+
if (!classMatch) return;
|
|
321
|
+
|
|
322
|
+
const className = classMatch[1];
|
|
323
|
+
const cssText = rule.toString();
|
|
324
|
+
|
|
325
|
+
const existing = rules.get(className) || [];
|
|
326
|
+
existing.push(cssText);
|
|
327
|
+
rules.set(className, existing);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// 4. Extract variant definitions
|
|
331
|
+
const variants = new Map<string, string>();
|
|
332
|
+
ast.walkAtRules('media', (atRule) => {
|
|
333
|
+
// Map responsive prefixes to media queries
|
|
334
|
+
if (atRule.params.includes('min-width: 640px')) {
|
|
335
|
+
variants.set('sm:', atRule.params);
|
|
336
|
+
}
|
|
337
|
+
// ... etc for md, lg, xl, 2xl
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return { version: '1.0.0', rules, variants };
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### 3. Asset Manifest Generation (Build Time)
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// packages/build/src/asset-manifest.ts
|
|
348
|
+
|
|
349
|
+
interface AssetManifest {
|
|
350
|
+
version: string;
|
|
351
|
+
assets: Map<string, AssetEntry>;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
interface AssetEntry {
|
|
355
|
+
originalPath: string;
|
|
356
|
+
dataUri: string;
|
|
357
|
+
size: number;
|
|
358
|
+
format: string;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function generateAssetManifest(
|
|
362
|
+
assetsDir: string,
|
|
363
|
+
options: AssetOptions
|
|
364
|
+
): Promise<AssetManifest> {
|
|
365
|
+
const assets = new Map<string, AssetEntry>();
|
|
366
|
+
|
|
367
|
+
// Scan all asset files
|
|
368
|
+
for await (const file of glob(`${assetsDir}/**/*.{svg,png,jpg,jpeg,gif,webp,ico}`)) {
|
|
369
|
+
const relativePath = path.relative(assetsDir, file);
|
|
370
|
+
const buffer = await Bun.file(file).arrayBuffer();
|
|
371
|
+
|
|
372
|
+
// Skip if too large
|
|
373
|
+
if (buffer.byteLength > options.maxInlineSize) {
|
|
374
|
+
console.warn(`Skipping ${relativePath}: too large (${buffer.byteLength} bytes)`);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let dataUri: string;
|
|
379
|
+
|
|
380
|
+
if (file.endsWith('.svg')) {
|
|
381
|
+
// SVG: Optimize with SVGO, then inline
|
|
382
|
+
const svgContent = await optimizeSVG(await Bun.file(file).text());
|
|
383
|
+
const base64 = Buffer.from(svgContent).toString('base64');
|
|
384
|
+
dataUri = `data:image/svg+xml;base64,${base64}`;
|
|
385
|
+
} else {
|
|
386
|
+
// Raster: Optionally convert to WebP, then base64
|
|
387
|
+
let finalBuffer = buffer;
|
|
388
|
+
let mimeType = getMimeType(file);
|
|
389
|
+
|
|
390
|
+
if (options.convertToWebP && !file.endsWith('.webp')) {
|
|
391
|
+
finalBuffer = await convertToWebP(buffer, options.webpQuality);
|
|
392
|
+
mimeType = 'image/webp';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const base64 = Buffer.from(finalBuffer).toString('base64');
|
|
396
|
+
dataUri = `data:${mimeType};base64,${base64}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
assets.set(`/${relativePath}`, {
|
|
400
|
+
originalPath: file,
|
|
401
|
+
dataUri,
|
|
402
|
+
size: buffer.byteLength,
|
|
403
|
+
format: path.extname(file).slice(1),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { version: '1.0.0', assets };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function optimizeSVG(svgContent: string): Promise<string> {
|
|
411
|
+
const { optimize } = await import('svgo');
|
|
412
|
+
|
|
413
|
+
const result = optimize(svgContent, {
|
|
414
|
+
multipass: true,
|
|
415
|
+
plugins: [
|
|
416
|
+
'preset-default',
|
|
417
|
+
'removeDimensions',
|
|
418
|
+
{
|
|
419
|
+
name: 'removeAttrs',
|
|
420
|
+
params: { attrs: ['data-name', 'class'] },
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return result.data;
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### 4. Font Manifest Generation (Build Time)
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
// packages/build/src/font-manifest.ts
|
|
433
|
+
|
|
434
|
+
interface FontManifest {
|
|
435
|
+
version: string;
|
|
436
|
+
fonts: Map<string, FontEntry>;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
interface FontEntry {
|
|
440
|
+
family: string;
|
|
441
|
+
weight: number;
|
|
442
|
+
style: 'normal' | 'italic';
|
|
443
|
+
dataUri: string;
|
|
444
|
+
unicodeRange?: string;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function generateFontManifest(
|
|
448
|
+
fontsDir: string,
|
|
449
|
+
options: FontOptions
|
|
450
|
+
): Promise<FontManifest> {
|
|
451
|
+
const fonts = new Map<string, FontEntry>();
|
|
452
|
+
|
|
453
|
+
for await (const file of glob(`${fontsDir}/**/*.woff2`)) {
|
|
454
|
+
const buffer = await Bun.file(file).arrayBuffer();
|
|
455
|
+
|
|
456
|
+
// Optional: Subset font to reduce size
|
|
457
|
+
let finalBuffer = buffer;
|
|
458
|
+
if (options.subset) {
|
|
459
|
+
finalBuffer = await subsetFont(buffer, options.subset);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const base64 = Buffer.from(finalBuffer).toString('base64');
|
|
463
|
+
const { family, weight, style } = parseFileName(file);
|
|
464
|
+
|
|
465
|
+
fonts.set(`${family}-${weight}-${style}`, {
|
|
466
|
+
family,
|
|
467
|
+
weight,
|
|
468
|
+
style,
|
|
469
|
+
dataUri: `data:font/woff2;base64,${base64}`,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return { version: '1.0.0', fonts };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Parse font file name like "Inter-Bold.woff2" → { family: "Inter", weight: 700, style: "normal" }
|
|
477
|
+
function parseFileName(file: string): { family: string; weight: number; style: 'normal' | 'italic' } {
|
|
478
|
+
const name = path.basename(file, '.woff2');
|
|
479
|
+
const parts = name.split('-');
|
|
480
|
+
|
|
481
|
+
const family = parts[0];
|
|
482
|
+
const weightMap: Record<string, number> = {
|
|
483
|
+
'Thin': 100, 'ExtraLight': 200, 'Light': 300, 'Regular': 400,
|
|
484
|
+
'Medium': 500, 'SemiBold': 600, 'Bold': 700, 'ExtraBold': 800, 'Black': 900,
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
let weight = 400;
|
|
488
|
+
let style: 'normal' | 'italic' = 'normal';
|
|
489
|
+
|
|
490
|
+
for (const part of parts.slice(1)) {
|
|
491
|
+
if (weightMap[part]) weight = weightMap[part];
|
|
492
|
+
if (part === 'Italic') style = 'italic';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return { family, weight, style };
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### 5. Multi-Layer Cache Implementation
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
// packages/runtime/src/cache.ts
|
|
503
|
+
|
|
504
|
+
interface CacheConfig {
|
|
505
|
+
edge: { ttl: number };
|
|
506
|
+
kv: { ttl: number; namespace: string };
|
|
507
|
+
d1: { table: string };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
class PageCache {
|
|
511
|
+
constructor(
|
|
512
|
+
private env: Env,
|
|
513
|
+
private config: CacheConfig
|
|
514
|
+
) {}
|
|
515
|
+
|
|
516
|
+
async get(route: string): Promise<string | null> {
|
|
517
|
+
// Layer 1: Edge cache (handled by Cloudflare automatically via headers)
|
|
518
|
+
|
|
519
|
+
// Layer 2: KV cache
|
|
520
|
+
const kvKey = `page:${route}`;
|
|
521
|
+
const kvCached = await this.env.CACHE.get(kvKey);
|
|
522
|
+
if (kvCached) {
|
|
523
|
+
console.log(`[cache] KV hit: ${route}`);
|
|
524
|
+
return kvCached;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Layer 3: D1 cache
|
|
528
|
+
const d1Result = await this.env.DB
|
|
529
|
+
.prepare(`SELECT html FROM ${this.config.d1.table} WHERE route = ?`)
|
|
530
|
+
.bind(route)
|
|
531
|
+
.first<{ html: string }>();
|
|
532
|
+
|
|
533
|
+
if (d1Result) {
|
|
534
|
+
console.log(`[cache] D1 hit: ${route}`);
|
|
535
|
+
// Promote to KV
|
|
536
|
+
await this.env.CACHE.put(kvKey, d1Result.html, {
|
|
537
|
+
expirationTtl: this.config.kv.ttl,
|
|
538
|
+
});
|
|
539
|
+
return d1Result.html;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
console.log(`[cache] miss: ${route}`);
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async set(route: string, html: string): Promise<void> {
|
|
547
|
+
const kvKey = `page:${route}`;
|
|
548
|
+
|
|
549
|
+
// Store in both KV and D1
|
|
550
|
+
await Promise.all([
|
|
551
|
+
this.env.CACHE.put(kvKey, html, {
|
|
552
|
+
expirationTtl: this.config.kv.ttl,
|
|
553
|
+
}),
|
|
554
|
+
this.env.DB
|
|
555
|
+
.prepare(`
|
|
556
|
+
INSERT OR REPLACE INTO ${this.config.d1.table} (route, html, rendered_at)
|
|
557
|
+
VALUES (?, ?, datetime('now'))
|
|
558
|
+
`)
|
|
559
|
+
.bind(route, html)
|
|
560
|
+
.run(),
|
|
561
|
+
]);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async invalidate(routes: string[]): Promise<void> {
|
|
565
|
+
await Promise.all([
|
|
566
|
+
// Clear KV
|
|
567
|
+
...routes.map(route => this.env.CACHE.delete(`page:${route}`)),
|
|
568
|
+
|
|
569
|
+
// Clear D1
|
|
570
|
+
this.env.DB
|
|
571
|
+
.prepare(`DELETE FROM ${this.config.d1.table} WHERE route IN (${routes.map(() => '?').join(',')})`)
|
|
572
|
+
.bind(...routes)
|
|
573
|
+
.run(),
|
|
574
|
+
]);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### 6. Worker Entry Point
|
|
580
|
+
|
|
581
|
+
```typescript
|
|
582
|
+
// packages/runtime/src/worker.ts
|
|
583
|
+
|
|
584
|
+
import { AeonRenderer } from './renderer.wasm';
|
|
585
|
+
|
|
586
|
+
export default {
|
|
587
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
588
|
+
const url = new URL(request.url);
|
|
589
|
+
const route = url.pathname;
|
|
590
|
+
|
|
591
|
+
// Static assets bypass rendering
|
|
592
|
+
if (route.startsWith('/_aeon/')) {
|
|
593
|
+
return handleStaticAsset(route, env);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Initialize cache
|
|
597
|
+
const cache = new PageCache(env, {
|
|
598
|
+
edge: { ttl: 3600 },
|
|
599
|
+
kv: { ttl: 3600, namespace: 'CACHE' },
|
|
600
|
+
d1: { table: 'rendered_pages' },
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// Check cache first
|
|
604
|
+
const cached = await cache.get(route);
|
|
605
|
+
if (cached) {
|
|
606
|
+
return new Response(cached, {
|
|
607
|
+
headers: {
|
|
608
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
609
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
|
|
610
|
+
'X-Aeon-Cache': 'hit',
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Load manifests
|
|
616
|
+
const [cssManifest, assetManifest, fontManifest, pageSession] = await Promise.all([
|
|
617
|
+
loadManifest(env, 'css'),
|
|
618
|
+
loadManifest(env, 'assets'),
|
|
619
|
+
loadManifest(env, 'fonts'),
|
|
620
|
+
loadPageSession(env, route),
|
|
621
|
+
]);
|
|
622
|
+
|
|
623
|
+
if (!pageSession) {
|
|
624
|
+
return new Response('Not Found', { status: 404 });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Render with WASM
|
|
628
|
+
const renderer = new AeonRenderer(cssManifest, assetManifest, fontManifest);
|
|
629
|
+
const output = renderer.render(pageSession.tree);
|
|
630
|
+
|
|
631
|
+
// Assemble final HTML
|
|
632
|
+
const html = assembleHTML(output, pageSession, env);
|
|
633
|
+
|
|
634
|
+
// Cache for next time (non-blocking)
|
|
635
|
+
ctx.waitUntil(cache.set(route, html));
|
|
636
|
+
|
|
637
|
+
return new Response(html, {
|
|
638
|
+
headers: {
|
|
639
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
640
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
|
|
641
|
+
'X-Aeon-Cache': 'miss',
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
function assembleHTML(output: RenderOutput, session: PageSession, env: Env): string {
|
|
648
|
+
const title = session.data.title || 'AFFECTIVELY';
|
|
649
|
+
const description = session.data.description || '';
|
|
650
|
+
|
|
651
|
+
return `<!DOCTYPE html>
|
|
652
|
+
<html lang="en">
|
|
653
|
+
<head>
|
|
654
|
+
<meta charset="UTF-8">
|
|
655
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
656
|
+
<title>${escapeHtml(title)}</title>
|
|
657
|
+
${description ? `<meta name="description" content="${escapeHtml(description)}">` : ''}
|
|
658
|
+
<style>${output.fonts}${output.css}</style>
|
|
659
|
+
</head>
|
|
660
|
+
<body>
|
|
661
|
+
<div id="root">${output.html}</div>
|
|
662
|
+
${output.critical_js}
|
|
663
|
+
</body>
|
|
664
|
+
</html>`;
|
|
665
|
+
}
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
---
|
|
669
|
+
|
|
670
|
+
## Performance Expectations
|
|
671
|
+
|
|
672
|
+
### Render Time Breakdown (WASM)
|
|
673
|
+
|
|
674
|
+
| Operation | JavaScript | WASM | Speedup |
|
|
675
|
+
|-----------|------------|------|---------|
|
|
676
|
+
| Parse AST (1000 nodes) | 15ms | 2ms | 7.5x |
|
|
677
|
+
| Walk tree | 8ms | 0.5ms | 16x |
|
|
678
|
+
| CSS collection | 12ms | 1ms | 12x |
|
|
679
|
+
| Asset resolution | 20ms | 3ms | 6.7x |
|
|
680
|
+
| HTML generation | 10ms | 1ms | 10x |
|
|
681
|
+
| **Total** | **65ms** | **7.5ms** | **8.7x** |
|
|
682
|
+
|
|
683
|
+
### Page Size Comparison
|
|
684
|
+
|
|
685
|
+
| Resource | Current | Target | Reduction |
|
|
686
|
+
|----------|---------|--------|-----------|
|
|
687
|
+
| HTML | 5KB | 15KB | -200% (larger, but includes everything) |
|
|
688
|
+
| CSS | 200KB (external) | 10KB (inline) | 95% |
|
|
689
|
+
| Fonts | 150KB (external) | 30KB (inline, subset) | 80% |
|
|
690
|
+
| Images | 100KB (external) | 50KB (inline, optimized) | 50% |
|
|
691
|
+
| JS | 200KB (external) | 5KB (critical only) | 97% |
|
|
692
|
+
| **Total Requests** | 15-30 | 1 | 97%+ |
|
|
693
|
+
| **Total Bytes** | ~655KB | ~110KB | 83% |
|
|
694
|
+
|
|
695
|
+
### Time to First Paint
|
|
696
|
+
|
|
697
|
+
| Metric | Current | Target | Improvement |
|
|
698
|
+
|--------|---------|--------|-------------|
|
|
699
|
+
| TTFB | 100ms | 50ms | 50% |
|
|
700
|
+
| FCP | 500ms | 100ms | 80% |
|
|
701
|
+
| LCP | 1200ms | 200ms | 83% |
|
|
702
|
+
| TTI | 2000ms | 300ms | 85% |
|
|
703
|
+
| CLS | 0.05 | 0 | 100% |
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
## Migration Path
|
|
708
|
+
|
|
709
|
+
### Phase 1: Build Manifests (Week 1-2)
|
|
710
|
+
- Implement CSS manifest generation
|
|
711
|
+
- Implement asset manifest generation
|
|
712
|
+
- Implement font manifest generation
|
|
713
|
+
- Store in D1 at deploy time
|
|
714
|
+
|
|
715
|
+
### Phase 2: WASM Renderer (Week 3-4)
|
|
716
|
+
- Port AST walker to Rust
|
|
717
|
+
- Implement CSS collection
|
|
718
|
+
- Implement asset resolution
|
|
719
|
+
- Benchmark against JS renderer
|
|
720
|
+
|
|
721
|
+
### Phase 3: Cache Layers (Week 5-6)
|
|
722
|
+
- Implement KV page cache
|
|
723
|
+
- Implement D1 page cache
|
|
724
|
+
- Add cache invalidation on deploy
|
|
725
|
+
- Add cache warming for popular routes
|
|
726
|
+
|
|
727
|
+
### Phase 4: Progressive Rollout (Week 7-8)
|
|
728
|
+
- A/B test against current system
|
|
729
|
+
- Monitor performance metrics
|
|
730
|
+
- Fix edge cases
|
|
731
|
+
- Full rollout
|
|
732
|
+
|
|
733
|
+
### Phase 5: Hydration Optimization (Week 9-10)
|
|
734
|
+
- Implement lazy hydration
|
|
735
|
+
- Reduce critical JS to minimum
|
|
736
|
+
- Add interaction observers
|
|
737
|
+
- Test interactive components
|
|
738
|
+
|
|
739
|
+
---
|
|
740
|
+
|
|
741
|
+
## Speculative Pre-Rendering
|
|
742
|
+
|
|
743
|
+
### The Vision: Zero-Latency Navigation
|
|
744
|
+
|
|
745
|
+
Since Aeon already has a speculation engine predicting the next likely click, we can **pre-render entire pages in memory** before the user clicks. When they do click, it's just a DOM swap - no network request, no rendering delay.
|
|
746
|
+
|
|
747
|
+
```
|
|
748
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
749
|
+
│ SPECULATIVE PRE-RENDERING │
|
|
750
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
751
|
+
│ │
|
|
752
|
+
│ Current Page Loaded │
|
|
753
|
+
│ │ │
|
|
754
|
+
│ ▼ │
|
|
755
|
+
│ ┌─────────────────┐ │
|
|
756
|
+
│ │ Speculation │───▶ Predict next clicks: [/dashboard, /explore, /chat] │
|
|
757
|
+
│ │ Engine │ (based on: link proximity, user history, ML model) │
|
|
758
|
+
│ └─────────────────┘ │
|
|
759
|
+
│ │ │
|
|
760
|
+
│ ▼ │
|
|
761
|
+
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
|
762
|
+
│ │ Pre-Render │ │ Pre-Render │ │ Pre-Render │ │
|
|
763
|
+
│ │ /dashboard │ │ /explore │ │ /chat │ │
|
|
764
|
+
│ │ (full HTML) │ │ (full HTML) │ │ (full HTML) │ │
|
|
765
|
+
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
|
|
766
|
+
│ │ │ │ │
|
|
767
|
+
│ └───────────────────────┴───────────────────────┘ │
|
|
768
|
+
│ │ │
|
|
769
|
+
│ ▼ │
|
|
770
|
+
│ ┌──────────────────────────────┐ │
|
|
771
|
+
│ │ In-Memory Page Cache │ │
|
|
772
|
+
│ │ │ │
|
|
773
|
+
│ │ Map<route, { │ │
|
|
774
|
+
│ │ html: string, │ │
|
|
775
|
+
│ │ prefetchedAt: Date, │ │
|
|
776
|
+
│ │ confidence: number, │ │
|
|
777
|
+
│ │ stale: boolean │ │
|
|
778
|
+
│ │ }> │ │
|
|
779
|
+
│ │ │ │
|
|
780
|
+
│ └──────────────────────────────┘ │
|
|
781
|
+
│ │ │
|
|
782
|
+
│ ▼ │
|
|
783
|
+
│ User Clicks Link ──────────▶ Instant DOM Swap (0ms network, 0ms render) │
|
|
784
|
+
│ │
|
|
785
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
### Implementation
|
|
789
|
+
|
|
790
|
+
```typescript
|
|
791
|
+
// packages/runtime/src/speculation.ts
|
|
792
|
+
|
|
793
|
+
interface PreRenderedPage {
|
|
794
|
+
route: string;
|
|
795
|
+
html: string;
|
|
796
|
+
prefetchedAt: number;
|
|
797
|
+
confidence: number;
|
|
798
|
+
stale: boolean;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
class SpeculativeRenderer {
|
|
802
|
+
private cache = new Map<string, PreRenderedPage>();
|
|
803
|
+
private renderer: AeonRenderer;
|
|
804
|
+
private observer: IntersectionObserver;
|
|
805
|
+
|
|
806
|
+
constructor(renderer: AeonRenderer) {
|
|
807
|
+
this.renderer = renderer;
|
|
808
|
+
|
|
809
|
+
// Watch for links entering viewport
|
|
810
|
+
this.observer = new IntersectionObserver(
|
|
811
|
+
(entries) => this.onLinksVisible(entries),
|
|
812
|
+
{ rootMargin: '200px' } // Pre-render when link is 200px from viewport
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
init() {
|
|
817
|
+
// Observe all internal links
|
|
818
|
+
document.querySelectorAll('a[href^="/"]').forEach((link) => {
|
|
819
|
+
this.observer.observe(link);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
// Also use speculation rules if browser supports them
|
|
823
|
+
this.injectSpeculationRules();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private async onLinksVisible(entries: IntersectionObserverEntry[]) {
|
|
827
|
+
for (const entry of entries) {
|
|
828
|
+
if (!entry.isIntersecting) continue;
|
|
829
|
+
|
|
830
|
+
const link = entry.target as HTMLAnchorElement;
|
|
831
|
+
const route = new URL(link.href).pathname;
|
|
832
|
+
|
|
833
|
+
// Skip if already cached
|
|
834
|
+
if (this.cache.has(route)) continue;
|
|
835
|
+
|
|
836
|
+
// Pre-render this page
|
|
837
|
+
await this.preRender(route);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private async preRender(route: string): Promise<void> {
|
|
842
|
+
console.log(`[speculation] Pre-rendering: ${route}`);
|
|
843
|
+
|
|
844
|
+
// Fetch page session from worker
|
|
845
|
+
const response = await fetch(`/_aeon/session${route}`);
|
|
846
|
+
if (!response.ok) return;
|
|
847
|
+
|
|
848
|
+
const session = await response.json();
|
|
849
|
+
|
|
850
|
+
// Render to HTML using WASM
|
|
851
|
+
const output = this.renderer.render(session.tree);
|
|
852
|
+
const html = this.assembleHTML(output, session);
|
|
853
|
+
|
|
854
|
+
// Store in memory
|
|
855
|
+
this.cache.set(route, {
|
|
856
|
+
route,
|
|
857
|
+
html,
|
|
858
|
+
prefetchedAt: Date.now(),
|
|
859
|
+
confidence: 0.8,
|
|
860
|
+
stale: false,
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
console.log(`[speculation] Cached: ${route} (${html.length} bytes)`);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private assembleHTML(output: RenderOutput, session: PageSession): string {
|
|
867
|
+
return `<!DOCTYPE html>
|
|
868
|
+
<html lang="en">
|
|
869
|
+
<head>
|
|
870
|
+
<meta charset="UTF-8">
|
|
871
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
872
|
+
<title>${session.data.title || 'AFFECTIVELY'}</title>
|
|
873
|
+
<style>${output.fonts}${output.css}</style>
|
|
874
|
+
</head>
|
|
875
|
+
<body>
|
|
876
|
+
<div id="root">${output.html}</div>
|
|
877
|
+
${output.critical_js}
|
|
878
|
+
</body>
|
|
879
|
+
</html>`;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private injectSpeculationRules() {
|
|
883
|
+
// Use browser's native speculation rules API when available
|
|
884
|
+
const rules = {
|
|
885
|
+
prerender: [
|
|
886
|
+
{
|
|
887
|
+
source: 'document',
|
|
888
|
+
where: { href_matches: '/*' },
|
|
889
|
+
eagerness: 'moderate',
|
|
890
|
+
},
|
|
891
|
+
],
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const script = document.createElement('script');
|
|
895
|
+
script.type = 'speculationrules';
|
|
896
|
+
script.textContent = JSON.stringify(rules);
|
|
897
|
+
document.head.appendChild(script);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Called when user clicks a link
|
|
901
|
+
async navigate(route: string): Promise<boolean> {
|
|
902
|
+
const cached = this.cache.get(route);
|
|
903
|
+
|
|
904
|
+
if (cached && !cached.stale) {
|
|
905
|
+
// Instant navigation - just swap the HTML
|
|
906
|
+
document.open();
|
|
907
|
+
document.write(cached.html);
|
|
908
|
+
document.close();
|
|
909
|
+
|
|
910
|
+
// Update URL
|
|
911
|
+
history.pushState({}, '', route);
|
|
912
|
+
|
|
913
|
+
// Re-initialize speculation for new page
|
|
914
|
+
this.init();
|
|
915
|
+
|
|
916
|
+
console.log(`[speculation] Instant nav to: ${route}`);
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Not cached - fall back to normal navigation
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Invalidate cached pages (called on data changes)
|
|
925
|
+
invalidate(routes?: string[]) {
|
|
926
|
+
if (routes) {
|
|
927
|
+
routes.forEach((route) => {
|
|
928
|
+
const cached = this.cache.get(route);
|
|
929
|
+
if (cached) cached.stale = true;
|
|
930
|
+
});
|
|
931
|
+
} else {
|
|
932
|
+
this.cache.forEach((page) => (page.stale = true));
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Integration with link clicks
|
|
938
|
+
document.addEventListener('click', async (e) => {
|
|
939
|
+
const link = (e.target as Element).closest('a[href^="/"]');
|
|
940
|
+
if (!link) return;
|
|
941
|
+
|
|
942
|
+
const route = new URL((link as HTMLAnchorElement).href).pathname;
|
|
943
|
+
|
|
944
|
+
// Try speculative navigation first
|
|
945
|
+
if (await speculativeRenderer.navigate(route)) {
|
|
946
|
+
e.preventDefault();
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
### On-Demand CSS (Tailwind v4 Style)
|
|
952
|
+
|
|
953
|
+
The key insight from Tailwind v4's Lightning CSS: generate CSS **only for the classes actually used** at render time.
|
|
954
|
+
|
|
955
|
+
```typescript
|
|
956
|
+
// packages/runtime/src/on-demand-css.ts
|
|
957
|
+
|
|
958
|
+
class OnDemandCSS {
|
|
959
|
+
private ruleIndex: Map<string, string>; // className → CSS rule
|
|
960
|
+
private generated = new Set<string>(); // Already generated classes
|
|
961
|
+
|
|
962
|
+
constructor(cssManifest: CSSManifest) {
|
|
963
|
+
this.ruleIndex = cssManifest.rules;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Generate CSS for a specific set of classes
|
|
967
|
+
generate(classes: Set<string>): string {
|
|
968
|
+
const newClasses = [...classes].filter((c) => !this.generated.has(c));
|
|
969
|
+
|
|
970
|
+
if (newClasses.length === 0) return '';
|
|
971
|
+
|
|
972
|
+
const css: string[] = [];
|
|
973
|
+
|
|
974
|
+
for (const className of newClasses) {
|
|
975
|
+
const rule = this.ruleIndex.get(className);
|
|
976
|
+
if (rule) {
|
|
977
|
+
css.push(rule);
|
|
978
|
+
this.generated.add(className);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return css.join('\n');
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Generate CSS for a component tree
|
|
986
|
+
generateForTree(tree: ComponentNode): string {
|
|
987
|
+
const classes = this.extractClasses(tree);
|
|
988
|
+
return this.generate(classes);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private extractClasses(node: ComponentNode, classes = new Set<string>()): Set<string> {
|
|
992
|
+
if (node.className) {
|
|
993
|
+
node.className.split(/\s+/).forEach((c) => c && classes.add(c));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
for (const child of node.children || []) {
|
|
997
|
+
if (typeof child === 'object') {
|
|
998
|
+
this.extractClasses(child, classes);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return classes;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
### The Complete Flow
|
|
1008
|
+
|
|
1009
|
+
```
|
|
1010
|
+
User lands on /home
|
|
1011
|
+
│
|
|
1012
|
+
▼
|
|
1013
|
+
┌──────────────────┐
|
|
1014
|
+
│ 1. Render /home │──▶ On-demand CSS (only /home classes) + inline assets
|
|
1015
|
+
│ with WASM │
|
|
1016
|
+
└──────────────────┘
|
|
1017
|
+
│
|
|
1018
|
+
▼
|
|
1019
|
+
┌──────────────────┐
|
|
1020
|
+
│ 2. Speculation │──▶ Predict next: [/dashboard, /explore, /chat]
|
|
1021
|
+
│ Engine runs │
|
|
1022
|
+
└──────────────────┘
|
|
1023
|
+
│
|
|
1024
|
+
▼
|
|
1025
|
+
┌──────────────────┐
|
|
1026
|
+
│ 3. Pre-render │──▶ Full HTML for each, stored in memory
|
|
1027
|
+
│ predicted │ (each with their own on-demand CSS)
|
|
1028
|
+
│ pages │
|
|
1029
|
+
└──────────────────┘
|
|
1030
|
+
│
|
|
1031
|
+
▼
|
|
1032
|
+
User clicks /dashboard
|
|
1033
|
+
│
|
|
1034
|
+
▼
|
|
1035
|
+
┌──────────────────┐
|
|
1036
|
+
│ 4. DOM swap │──▶ 0ms network, 0ms render
|
|
1037
|
+
│ from cache │ Instant page transition
|
|
1038
|
+
└──────────────────┘
|
|
1039
|
+
│
|
|
1040
|
+
▼
|
|
1041
|
+
┌──────────────────┐
|
|
1042
|
+
│ 5. Re-speculate │──▶ Predict next pages from /dashboard
|
|
1043
|
+
│ for new page │
|
|
1044
|
+
└──────────────────┘
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
This creates a **feel of a native app** - every navigation is instant because the page is already in memory, fully rendered with exactly the CSS it needs.
|
|
1048
|
+
|
|
1049
|
+
---
|
|
1050
|
+
|
|
1051
|
+
## Build-Time Pre-Rendering (Static Generation)
|
|
1052
|
+
|
|
1053
|
+
### The Ultimate Optimization: Pre-Render Everything at Build
|
|
1054
|
+
|
|
1055
|
+
Why wait for runtime? We can **pre-render every single page during the build process** and seed them directly into D1/KV. First request for ANY route is a cache hit.
|
|
1056
|
+
|
|
1057
|
+
```
|
|
1058
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
1059
|
+
│ BUILD TIME PRE-RENDERING │
|
|
1060
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
1061
|
+
│ │
|
|
1062
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │
|
|
1063
|
+
│ │ All Routes │───▶│ WASM │───▶│ Rendered HTML │ │
|
|
1064
|
+
│ │ from D1 │ │ Renderer │ │ (with inline CSS, assets) │ │
|
|
1065
|
+
│ └──────────────┘ └──────────────┘ └──────────────────────────────┘ │
|
|
1066
|
+
│ │ │
|
|
1067
|
+
│ ▼ │
|
|
1068
|
+
│ ┌───────────────────────────────────────────┐ │
|
|
1069
|
+
│ │ D1 Seed SQL │ │
|
|
1070
|
+
│ │ │ │
|
|
1071
|
+
│ │ INSERT INTO rendered_pages VALUES │ │
|
|
1072
|
+
│ │ ('/', '<html>...</html>'), │ │
|
|
1073
|
+
│ │ ('/about', '<html>...</html>'), │ │
|
|
1074
|
+
│ │ ('/dashboard', '<html>...</html>'), │ │
|
|
1075
|
+
│ │ ('/explore', '<html>...</html>'), │ │
|
|
1076
|
+
│ │ ... (every route pre-rendered) │ │
|
|
1077
|
+
│ │ │ │
|
|
1078
|
+
│ └───────────────────────────────────────────┘ │
|
|
1079
|
+
│ │ │
|
|
1080
|
+
│ ▼ │
|
|
1081
|
+
│ ┌───────────────────────────────────────────┐ │
|
|
1082
|
+
│ │ wrangler d1 execute --file │ │
|
|
1083
|
+
│ │ (seed all pages at deploy) │ │
|
|
1084
|
+
│ └───────────────────────────────────────────┘ │
|
|
1085
|
+
│ │
|
|
1086
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
### Build Command
|
|
1090
|
+
|
|
1091
|
+
```typescript
|
|
1092
|
+
// packages/cli/src/commands/build.ts
|
|
1093
|
+
|
|
1094
|
+
export async function build(options: BuildOptions): Promise<void> {
|
|
1095
|
+
// ... existing steps ...
|
|
1096
|
+
|
|
1097
|
+
// Step 9: Pre-render all pages
|
|
1098
|
+
console.log('9️⃣ Pre-rendering all pages...');
|
|
1099
|
+
|
|
1100
|
+
const renderer = new AeonRenderer(cssManifest, assetManifest, fontManifest);
|
|
1101
|
+
const pages = await getAllPageSessions(db);
|
|
1102
|
+
const rendered: Array<{ route: string; html: string }> = [];
|
|
1103
|
+
|
|
1104
|
+
for (const page of pages) {
|
|
1105
|
+
const output = renderer.render(page.tree);
|
|
1106
|
+
const html = assembleHTML(output, page);
|
|
1107
|
+
|
|
1108
|
+
rendered.push({
|
|
1109
|
+
route: page.route,
|
|
1110
|
+
html,
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
console.log(` ✓ ${page.route} (${(html.length / 1024).toFixed(1)}KB)`);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Step 10: Generate seed SQL for pre-rendered pages
|
|
1117
|
+
console.log('🔟 Generating pre-render seed...');
|
|
1118
|
+
|
|
1119
|
+
const seedSQL = generatePreRenderSeed(rendered);
|
|
1120
|
+
await writeFile(
|
|
1121
|
+
join(outputDir, 'seed-prerender.sql'),
|
|
1122
|
+
seedSQL
|
|
1123
|
+
);
|
|
1124
|
+
|
|
1125
|
+
console.log(` ✓ seed-prerender.sql (${rendered.length} pages)`);
|
|
1126
|
+
|
|
1127
|
+
// Summary
|
|
1128
|
+
const totalSize = rendered.reduce((sum, p) => sum + p.html.length, 0);
|
|
1129
|
+
console.log(`\n📊 Pre-render summary:`);
|
|
1130
|
+
console.log(` Pages: ${rendered.length}`);
|
|
1131
|
+
console.log(` Total size: ${(totalSize / 1024 / 1024).toFixed(2)}MB`);
|
|
1132
|
+
console.log(` Avg per page: ${(totalSize / rendered.length / 1024).toFixed(1)}KB`);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function generatePreRenderSeed(pages: Array<{ route: string; html: string }>): string {
|
|
1136
|
+
const lines: string[] = [
|
|
1137
|
+
'-- Pre-rendered pages',
|
|
1138
|
+
`-- Generated: ${new Date().toISOString()}`,
|
|
1139
|
+
`-- Total pages: ${pages.length}`,
|
|
1140
|
+
'',
|
|
1141
|
+
'DELETE FROM rendered_pages;',
|
|
1142
|
+
'',
|
|
1143
|
+
];
|
|
1144
|
+
|
|
1145
|
+
for (const page of pages) {
|
|
1146
|
+
// Escape single quotes in HTML
|
|
1147
|
+
const escapedHtml = page.html.replace(/'/g, "''");
|
|
1148
|
+
lines.push(
|
|
1149
|
+
`INSERT INTO rendered_pages (route, html, version, rendered_at) VALUES ('${page.route}', '${escapedHtml}', '${process.env.DEPLOY_VERSION || '1.0.0'}', datetime('now'));`
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
return lines.join('\n');
|
|
1154
|
+
}
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
### Deploy Pipeline
|
|
1158
|
+
|
|
1159
|
+
```yaml
|
|
1160
|
+
# .github/workflows/deploy.yml
|
|
1161
|
+
|
|
1162
|
+
name: Deploy
|
|
1163
|
+
|
|
1164
|
+
on:
|
|
1165
|
+
push:
|
|
1166
|
+
branches: [main]
|
|
1167
|
+
|
|
1168
|
+
jobs:
|
|
1169
|
+
build-and-deploy:
|
|
1170
|
+
runs-on: ubuntu-latest
|
|
1171
|
+
steps:
|
|
1172
|
+
- uses: actions/checkout@v4
|
|
1173
|
+
|
|
1174
|
+
- name: Setup Bun
|
|
1175
|
+
uses: oven-sh/setup-bun@v1
|
|
1176
|
+
|
|
1177
|
+
- name: Install dependencies
|
|
1178
|
+
run: bun install
|
|
1179
|
+
|
|
1180
|
+
- name: Build (includes pre-rendering)
|
|
1181
|
+
run: bun run build
|
|
1182
|
+
env:
|
|
1183
|
+
DEPLOY_VERSION: ${{ github.sha }}
|
|
1184
|
+
|
|
1185
|
+
- name: Deploy to Cloudflare
|
|
1186
|
+
run: |
|
|
1187
|
+
# 1. Deploy D1 migrations
|
|
1188
|
+
wrangler d1 execute aeon-flux --file=.aeon/migrations/0001_initial.sql
|
|
1189
|
+
|
|
1190
|
+
# 2. Seed manifests
|
|
1191
|
+
wrangler d1 execute aeon-flux --file=.aeon/seed-manifests.sql
|
|
1192
|
+
|
|
1193
|
+
# 3. Seed pre-rendered pages
|
|
1194
|
+
wrangler d1 execute aeon-flux --file=.aeon/seed-prerender.sql
|
|
1195
|
+
|
|
1196
|
+
# 4. Deploy worker
|
|
1197
|
+
wrangler deploy
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
### Incremental Pre-Rendering
|
|
1201
|
+
|
|
1202
|
+
For large sites, we can do incremental pre-rendering:
|
|
1203
|
+
|
|
1204
|
+
```typescript
|
|
1205
|
+
// packages/cli/src/commands/build.ts
|
|
1206
|
+
|
|
1207
|
+
async function incrementalPreRender(
|
|
1208
|
+
renderer: AeonRenderer,
|
|
1209
|
+
db: D1Database,
|
|
1210
|
+
changedRoutes: string[]
|
|
1211
|
+
): Promise<void> {
|
|
1212
|
+
console.log(`Incremental pre-render: ${changedRoutes.length} pages`);
|
|
1213
|
+
|
|
1214
|
+
for (const route of changedRoutes) {
|
|
1215
|
+
const page = await getPageSession(db, route);
|
|
1216
|
+
if (!page) continue;
|
|
1217
|
+
|
|
1218
|
+
const output = renderer.render(page.tree);
|
|
1219
|
+
const html = assembleHTML(output, page);
|
|
1220
|
+
|
|
1221
|
+
// Update in D1
|
|
1222
|
+
await db
|
|
1223
|
+
.prepare(`
|
|
1224
|
+
INSERT OR REPLACE INTO rendered_pages (route, html, version, rendered_at)
|
|
1225
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
1226
|
+
`)
|
|
1227
|
+
.bind(route, html, process.env.DEPLOY_VERSION)
|
|
1228
|
+
.run();
|
|
1229
|
+
|
|
1230
|
+
console.log(` ✓ ${route}`);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Detect changed routes by comparing git diffs
|
|
1235
|
+
async function getChangedRoutes(): Promise<string[]> {
|
|
1236
|
+
const { stdout } = await exec('git diff --name-only HEAD~1');
|
|
1237
|
+
const changedFiles = stdout.split('\n');
|
|
1238
|
+
|
|
1239
|
+
// Map changed files to affected routes
|
|
1240
|
+
const routes = new Set<string>();
|
|
1241
|
+
|
|
1242
|
+
for (const file of changedFiles) {
|
|
1243
|
+
// Component change affects all routes using it
|
|
1244
|
+
if (file.includes('/components/')) {
|
|
1245
|
+
const componentName = path.basename(file, '.tsx');
|
|
1246
|
+
const affectedRoutes = await getRoutesUsingComponent(componentName);
|
|
1247
|
+
affectedRoutes.forEach((r) => routes.add(r));
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Page change affects that route
|
|
1251
|
+
if (file.includes('/pages/') && file.endsWith('page.tsx')) {
|
|
1252
|
+
const route = filePathToRoute(file);
|
|
1253
|
+
routes.add(route);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
return [...routes];
|
|
1258
|
+
}
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
### The Complete Build Output
|
|
1262
|
+
|
|
1263
|
+
```
|
|
1264
|
+
$ bun run build
|
|
1265
|
+
|
|
1266
|
+
🔨 Building Aeon Flux for production...
|
|
1267
|
+
|
|
1268
|
+
📁 Output: .aeon
|
|
1269
|
+
|
|
1270
|
+
1️⃣ Parsing pages...
|
|
1271
|
+
Found 47 page(s)
|
|
1272
|
+
|
|
1273
|
+
2️⃣ Generating route manifest...
|
|
1274
|
+
✓ manifest.json
|
|
1275
|
+
|
|
1276
|
+
3️⃣ Generating D1 migration...
|
|
1277
|
+
✓ migrations/0001_initial.sql
|
|
1278
|
+
|
|
1279
|
+
4️⃣ Generating D1 seed data...
|
|
1280
|
+
✓ seed.sql
|
|
1281
|
+
|
|
1282
|
+
5️⃣ Bundling WASM runtime...
|
|
1283
|
+
✓ runtime.wasm (18KB)
|
|
1284
|
+
|
|
1285
|
+
6️⃣ Generating CSS manifest...
|
|
1286
|
+
✓ css-manifest.json (2,847 rules)
|
|
1287
|
+
|
|
1288
|
+
7️⃣ Generating asset manifest...
|
|
1289
|
+
✓ asset-manifest.json (156 assets, 892KB inline)
|
|
1290
|
+
|
|
1291
|
+
8️⃣ Generating font manifest...
|
|
1292
|
+
✓ font-manifest.json (4 fonts, 124KB inline)
|
|
1293
|
+
|
|
1294
|
+
9️⃣ Pre-rendering all pages...
|
|
1295
|
+
✓ / (23.4KB)
|
|
1296
|
+
✓ /about (18.2KB)
|
|
1297
|
+
✓ /dashboard (31.5KB)
|
|
1298
|
+
✓ /explore (45.2KB)
|
|
1299
|
+
✓ /chat (28.7KB)
|
|
1300
|
+
... (42 more pages)
|
|
1301
|
+
|
|
1302
|
+
🔟 Generating pre-render seed...
|
|
1303
|
+
✓ seed-prerender.sql (47 pages)
|
|
1304
|
+
|
|
1305
|
+
📊 Pre-render summary:
|
|
1306
|
+
Pages: 47
|
|
1307
|
+
Total size: 1.34MB
|
|
1308
|
+
Avg per page: 29.2KB
|
|
1309
|
+
|
|
1310
|
+
✨ Build complete in 4.2s
|
|
1311
|
+
|
|
1312
|
+
Next steps:
|
|
1313
|
+
wrangler d1 execute aeon-flux --file=.aeon/migrations/0001_initial.sql
|
|
1314
|
+
wrangler d1 execute aeon-flux --file=.aeon/seed-prerender.sql
|
|
1315
|
+
wrangler deploy
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
### Runtime Flow with Pre-Rendered Pages
|
|
1319
|
+
|
|
1320
|
+
```
|
|
1321
|
+
Request: GET /dashboard
|
|
1322
|
+
│
|
|
1323
|
+
▼
|
|
1324
|
+
┌──────────────────┐
|
|
1325
|
+
│ Check D1 │──▶ SELECT html FROM rendered_pages WHERE route = '/dashboard'
|
|
1326
|
+
│ (pre-rendered) │
|
|
1327
|
+
└──────────────────┘
|
|
1328
|
+
│
|
|
1329
|
+
▼ (cache hit - always, because build pre-rendered it)
|
|
1330
|
+
│
|
|
1331
|
+
┌──────────────────┐
|
|
1332
|
+
│ Return HTML │──▶ <html>...entire page with inline CSS/assets...</html>
|
|
1333
|
+
│ (zero rendering) │
|
|
1334
|
+
└──────────────────┘
|
|
1335
|
+
│
|
|
1336
|
+
▼
|
|
1337
|
+
Response: 200 OK (15ms total, including D1 query)
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
**Every single page is a cache hit from the moment the site deploys.** There's never a cold render path for any known route.
|
|
1341
|
+
|
|
1342
|
+
---
|
|
1343
|
+
|
|
1344
|
+
## Open Questions
|
|
1345
|
+
|
|
1346
|
+
1. **Large Images**: What's the size threshold for inlining vs external? (Proposed: 10KB)
|
|
1347
|
+
2. **Dynamic Content**: How do we handle user-specific content? (Proposed: separate data loading)
|
|
1348
|
+
3. **Third-Party Scripts**: Analytics, chat widgets, etc.? (Proposed: load async post-paint)
|
|
1349
|
+
4. **SEO**: Will inline SVGs affect image SEO? (Proposed: test with GSC)
|
|
1350
|
+
5. **Memory**: What's the memory impact of large data URIs? (Proposed: benchmark)
|
|
1351
|
+
|
|
1352
|
+
---
|
|
1353
|
+
|
|
1354
|
+
## Appendix A: D1 Schema
|
|
1355
|
+
|
|
1356
|
+
```sql
|
|
1357
|
+
-- Render manifests
|
|
1358
|
+
CREATE TABLE IF NOT EXISTS render_manifests (
|
|
1359
|
+
type TEXT PRIMARY KEY, -- 'css', 'assets', 'fonts'
|
|
1360
|
+
manifest TEXT NOT NULL,
|
|
1361
|
+
version TEXT NOT NULL,
|
|
1362
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1363
|
+
);
|
|
1364
|
+
|
|
1365
|
+
-- Rendered pages cache
|
|
1366
|
+
CREATE TABLE IF NOT EXISTS rendered_pages (
|
|
1367
|
+
route TEXT PRIMARY KEY,
|
|
1368
|
+
html TEXT NOT NULL,
|
|
1369
|
+
version TEXT NOT NULL,
|
|
1370
|
+
rendered_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1371
|
+
);
|
|
1372
|
+
|
|
1373
|
+
-- Indexes
|
|
1374
|
+
CREATE INDEX IF NOT EXISTS idx_rendered_pages_time ON rendered_pages(rendered_at);
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
---
|
|
1378
|
+
|
|
1379
|
+
## Appendix B: Benchmark Results
|
|
1380
|
+
|
|
1381
|
+
*To be filled in during implementation*
|
|
1382
|
+
|
|
1383
|
+
---
|
|
1384
|
+
|
|
1385
|
+
## References
|
|
1386
|
+
|
|
1387
|
+
- [Why Not document.write()?](https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#document.write())
|
|
1388
|
+
- [Critical Rendering Path](https://web.dev/critical-rendering-path/)
|
|
1389
|
+
- [WASM Performance](https://webassembly.org/docs/faq/)
|
|
1390
|
+
- [Data URIs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs)
|
|
1391
|
+
- [Font Loading Strategies](https://web.dev/font-best-practices/)
|
|
1392
|
+
- [Resource Hints](https://web.dev/preload-critical-assets/)
|
|
1393
|
+
|
|
1394
|
+
---
|
|
1395
|
+
|
|
1396
|
+
## Implementation Status
|
|
1397
|
+
|
|
1398
|
+
### Phase 1: Build-Time Manifests ✅ COMPLETE
|
|
1399
|
+
|
|
1400
|
+
| Task | Status | Notes |
|
|
1401
|
+
|------|--------|-------|
|
|
1402
|
+
| CSS Manifest Generator | ✅ Complete | `packages/build/src/css-manifest.ts` - Generates CSS rules for Tailwind classes on-demand |
|
|
1403
|
+
| Asset Manifest Generator | ✅ Complete | `packages/build/src/asset-manifest.ts` - Inlines SVG/images as data URIs |
|
|
1404
|
+
| Font Manifest Generator | ✅ Complete | `packages/build/src/font-manifest.ts` - Embeds fonts as base64 data URIs |
|
|
1405
|
+
| Pre-render Module | ✅ Complete | `packages/build/src/prerender.ts` - Full page pre-rendering with inline CSS/assets/fonts |
|
|
1406
|
+
| Build Package Tests | ✅ Complete | 50 tests covering CSS generation, asset handling, pre-rendering |
|
|
1407
|
+
| CLI Integration | ✅ Complete | `packages/cli/src/commands/build.ts` - Integrated manifest building and pre-rendering |
|
|
1408
|
+
| D1 Manifest Storage | ✅ Complete | `generateManifestSeedSQL()` - Stores CSS/asset/font manifests in D1 |
|
|
1409
|
+
|
|
1410
|
+
### Phase 2: WASM Renderer ✅ COMPLETE
|
|
1411
|
+
|
|
1412
|
+
| Task | Status | Notes |
|
|
1413
|
+
|------|--------|-------|
|
|
1414
|
+
| Port AST walker to Rust/WASM | ✅ Complete | `render.rs` - `extract_css_classes()`, `walk_tree_for_classes()` |
|
|
1415
|
+
| Implement CSS collection in WASM | ✅ Complete | `render.rs` - `generate_css_for_classes()` |
|
|
1416
|
+
| Implement asset resolution in WASM | ✅ Complete | `render.rs` - `resolve_assets()`, `resolve_assets_in_value()` |
|
|
1417
|
+
| WASM renderer tests | ✅ Complete | 4 tests for rendering, CSS extraction, HTML escaping |
|
|
1418
|
+
|
|
1419
|
+
### Phase 3: Cache Layers ✅ COMPLETE
|
|
1420
|
+
|
|
1421
|
+
| Task | Status | Notes |
|
|
1422
|
+
|------|--------|-------|
|
|
1423
|
+
| KV page cache | ✅ Complete | `generateWorker()` - Layer 1 cache with `PAGES_CACHE` KV namespace |
|
|
1424
|
+
| D1 page cache | ✅ Complete | `getPreRenderedPage()` - Layer 2 cache from `rendered_pages` table |
|
|
1425
|
+
| Cache invalidation on deploy | ✅ Complete | `BUILD_VERSION` checking - stale cache auto-invalidated on new deploy |
|
|
1426
|
+
| Cache layer tests | ✅ Complete | 11 tests for multi-layer caching, version invalidation, cache headers |
|
|
1427
|
+
|
|
1428
|
+
### Phase 4: Build-Time Pre-Rendering ✅ COMPLETE
|
|
1429
|
+
|
|
1430
|
+
| Task | Status | Notes |
|
|
1431
|
+
|------|--------|-------|
|
|
1432
|
+
| Pre-render all pages at build | ✅ Complete | `prerenderAllPages()` - All pages pre-rendered during build |
|
|
1433
|
+
| Integration tests | ✅ Complete | 32 tests covering full build pipeline including pre-rendering |
|
|
1434
|
+
|
|
1435
|
+
### Phase 5: Speculative Pre-Rendering ⏳ IN PROGRESS
|
|
1436
|
+
|
|
1437
|
+
| Task | Status | Notes |
|
|
1438
|
+
|------|--------|-------|
|
|
1439
|
+
| Runtime speculative pre-rendering | ⏳ Pending | |
|
|
1440
|
+
| Lazy hydration | ✅ Complete | `generateHydrationScript()` - IntersectionObserver-based lazy hydration |
|
|
1441
|
+
| E2E tests | ⏳ Pending | |
|
|
1442
|
+
|
|
1443
|
+
---
|
|
1444
|
+
|
|
1445
|
+
*Document Version: 1.0.1*
|
|
1446
|
+
*Last Updated: 2026-02-06*
|