@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,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aeon Speculative Pre-Rendering
|
|
3
|
+
*
|
|
4
|
+
* Pre-renders pages before user clicks based on:
|
|
5
|
+
* 1. NavigationPredictor predictions (Markov chain, community patterns)
|
|
6
|
+
* 2. Link visibility (IntersectionObserver)
|
|
7
|
+
* 3. Hover intent signals
|
|
8
|
+
* 4. Browser Speculation Rules API (when available)
|
|
9
|
+
*
|
|
10
|
+
* This enables zero-latency navigation by having the page ready in memory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getPredictor, type PredictedRoute } from './predictor';
|
|
14
|
+
|
|
15
|
+
export interface PreRenderedPage {
|
|
16
|
+
route: string;
|
|
17
|
+
html: string;
|
|
18
|
+
prefetchedAt: number;
|
|
19
|
+
confidence: number;
|
|
20
|
+
stale: boolean;
|
|
21
|
+
size: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SpeculativeRendererConfig {
|
|
25
|
+
/** Maximum pages to keep in memory cache */
|
|
26
|
+
maxCachedPages: number;
|
|
27
|
+
/** Maximum total size in bytes for cache */
|
|
28
|
+
maxCacheSize: number;
|
|
29
|
+
/** Time before a cached page is considered stale (ms) */
|
|
30
|
+
staleTTL: number;
|
|
31
|
+
/** Minimum confidence threshold to pre-render */
|
|
32
|
+
minConfidence: number;
|
|
33
|
+
/** Root margin for IntersectionObserver */
|
|
34
|
+
intersectionRootMargin: string;
|
|
35
|
+
/** Whether to use browser's Speculation Rules API */
|
|
36
|
+
useSpeculationRules: boolean;
|
|
37
|
+
/** Whether to pre-render on hover */
|
|
38
|
+
prerenderOnHover: boolean;
|
|
39
|
+
/** Hover delay before pre-rendering (ms) */
|
|
40
|
+
hoverDelay: number;
|
|
41
|
+
/** Base URL for session fetches */
|
|
42
|
+
sessionBaseUrl: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_CONFIG: SpeculativeRendererConfig = {
|
|
46
|
+
maxCachedPages: 5,
|
|
47
|
+
maxCacheSize: 5 * 1024 * 1024, // 5MB
|
|
48
|
+
staleTTL: 5 * 60 * 1000, // 5 minutes
|
|
49
|
+
minConfidence: 0.3,
|
|
50
|
+
intersectionRootMargin: '200px',
|
|
51
|
+
useSpeculationRules: true,
|
|
52
|
+
prerenderOnHover: true,
|
|
53
|
+
hoverDelay: 100,
|
|
54
|
+
sessionBaseUrl: '/_aeon/session',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export class SpeculativeRenderer {
|
|
58
|
+
private config: SpeculativeRendererConfig;
|
|
59
|
+
private cache = new Map<string, PreRenderedPage>();
|
|
60
|
+
private currentCacheSize = 0;
|
|
61
|
+
private observer: IntersectionObserver | null = null;
|
|
62
|
+
private hoverTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
63
|
+
private initialized = false;
|
|
64
|
+
|
|
65
|
+
constructor(config: Partial<SpeculativeRendererConfig> = {}) {
|
|
66
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Initialize the speculative renderer
|
|
71
|
+
* Call this after the page has loaded
|
|
72
|
+
*/
|
|
73
|
+
init(): void {
|
|
74
|
+
if (this.initialized) return;
|
|
75
|
+
if (typeof window === 'undefined') return;
|
|
76
|
+
|
|
77
|
+
this.initialized = true;
|
|
78
|
+
|
|
79
|
+
// Setup IntersectionObserver for visible links
|
|
80
|
+
this.setupIntersectionObserver();
|
|
81
|
+
|
|
82
|
+
// Setup hover listeners if enabled
|
|
83
|
+
if (this.config.prerenderOnHover) {
|
|
84
|
+
this.setupHoverListeners();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Inject Speculation Rules if supported
|
|
88
|
+
if (this.config.useSpeculationRules) {
|
|
89
|
+
this.injectSpeculationRules();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Setup navigation interception
|
|
93
|
+
this.setupNavigationInterception();
|
|
94
|
+
|
|
95
|
+
// Start prediction-based pre-rendering
|
|
96
|
+
this.startPredictivePrerendering();
|
|
97
|
+
|
|
98
|
+
console.log('[aeon:speculation] Initialized');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Cleanup resources
|
|
103
|
+
*/
|
|
104
|
+
destroy(): void {
|
|
105
|
+
if (this.observer) {
|
|
106
|
+
this.observer.disconnect();
|
|
107
|
+
this.observer = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const timeout of this.hoverTimeouts.values()) {
|
|
111
|
+
clearTimeout(timeout);
|
|
112
|
+
}
|
|
113
|
+
this.hoverTimeouts.clear();
|
|
114
|
+
|
|
115
|
+
this.cache.clear();
|
|
116
|
+
this.currentCacheSize = 0;
|
|
117
|
+
this.initialized = false;
|
|
118
|
+
|
|
119
|
+
console.log('[aeon:speculation] Destroyed');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pre-render a specific route
|
|
124
|
+
*/
|
|
125
|
+
async prerender(route: string, confidence = 1): Promise<boolean> {
|
|
126
|
+
// Skip if already cached and not stale
|
|
127
|
+
const existing = this.cache.get(route);
|
|
128
|
+
if (
|
|
129
|
+
existing &&
|
|
130
|
+
!existing.stale &&
|
|
131
|
+
Date.now() - existing.prefetchedAt < this.config.staleTTL
|
|
132
|
+
) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Skip if current route
|
|
137
|
+
if (typeof window !== 'undefined' && window.location.pathname === route) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
console.log(`[aeon:speculation] Pre-rendering: ${route}`);
|
|
143
|
+
|
|
144
|
+
// Fetch the pre-rendered HTML from the server
|
|
145
|
+
const response = await fetch(`${route}?_aeon_prerender=1`, {
|
|
146
|
+
headers: {
|
|
147
|
+
'X-Aeon-Prerender': '1',
|
|
148
|
+
Accept: 'text/html',
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
console.warn(
|
|
154
|
+
`[aeon:speculation] Failed to fetch: ${route}`,
|
|
155
|
+
response.status,
|
|
156
|
+
);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const html = await response.text();
|
|
161
|
+
const size = html.length;
|
|
162
|
+
|
|
163
|
+
// Make room if needed
|
|
164
|
+
this.evictIfNeeded(size);
|
|
165
|
+
|
|
166
|
+
// Store in cache
|
|
167
|
+
const page: PreRenderedPage = {
|
|
168
|
+
route,
|
|
169
|
+
html,
|
|
170
|
+
prefetchedAt: Date.now(),
|
|
171
|
+
confidence,
|
|
172
|
+
stale: false,
|
|
173
|
+
size,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
this.cache.set(route, page);
|
|
177
|
+
this.currentCacheSize += size;
|
|
178
|
+
|
|
179
|
+
console.log(
|
|
180
|
+
`[aeon:speculation] Cached: ${route} (${(size / 1024).toFixed(1)}KB)`,
|
|
181
|
+
);
|
|
182
|
+
return true;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.warn(`[aeon:speculation] Error pre-rendering: ${route}`, err);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Navigate to a route using pre-rendered content if available
|
|
191
|
+
* Returns true if handled, false if fallback to normal navigation
|
|
192
|
+
*/
|
|
193
|
+
async navigate(route: string): Promise<boolean> {
|
|
194
|
+
const cached = this.cache.get(route);
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
cached &&
|
|
198
|
+
!cached.stale &&
|
|
199
|
+
Date.now() - cached.prefetchedAt < this.config.staleTTL
|
|
200
|
+
) {
|
|
201
|
+
console.log(`[aeon:speculation] Instant nav to: ${route}`);
|
|
202
|
+
|
|
203
|
+
// Instant navigation - replace document content
|
|
204
|
+
document.open();
|
|
205
|
+
document.write(cached.html);
|
|
206
|
+
document.close();
|
|
207
|
+
|
|
208
|
+
// Update URL
|
|
209
|
+
history.pushState({ aeonSpeculative: true }, '', route);
|
|
210
|
+
|
|
211
|
+
// Re-initialize for new page
|
|
212
|
+
this.reinitialize();
|
|
213
|
+
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Invalidate cached pages
|
|
222
|
+
*/
|
|
223
|
+
invalidate(routes?: string[]): void {
|
|
224
|
+
if (routes) {
|
|
225
|
+
for (const route of routes) {
|
|
226
|
+
const cached = this.cache.get(route);
|
|
227
|
+
if (cached) {
|
|
228
|
+
cached.stale = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// Mark all as stale
|
|
233
|
+
for (const page of this.cache.values()) {
|
|
234
|
+
page.stale = true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get cache statistics
|
|
241
|
+
*/
|
|
242
|
+
getStats(): {
|
|
243
|
+
cachedPages: number;
|
|
244
|
+
cacheSize: number;
|
|
245
|
+
cacheHitRate: number;
|
|
246
|
+
} {
|
|
247
|
+
return {
|
|
248
|
+
cachedPages: this.cache.size,
|
|
249
|
+
cacheSize: this.currentCacheSize,
|
|
250
|
+
cacheHitRate: 0, // Would need to track hits/misses
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------- Private Methods ----------
|
|
255
|
+
|
|
256
|
+
private setupIntersectionObserver(): void {
|
|
257
|
+
this.observer = new IntersectionObserver(
|
|
258
|
+
(entries) => this.onLinksVisible(entries),
|
|
259
|
+
{ rootMargin: this.config.intersectionRootMargin },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Observe all internal links
|
|
263
|
+
this.observeLinks();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private observeLinks(): void {
|
|
267
|
+
if (!this.observer) return;
|
|
268
|
+
|
|
269
|
+
document.querySelectorAll('a[href^="/"]').forEach((link) => {
|
|
270
|
+
this.observer!.observe(link);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async onLinksVisible(
|
|
275
|
+
entries: IntersectionObserverEntry[],
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
for (const entry of entries) {
|
|
278
|
+
if (!entry.isIntersecting) continue;
|
|
279
|
+
|
|
280
|
+
const link = entry.target as HTMLAnchorElement;
|
|
281
|
+
const route = new URL(link.href, window.location.origin).pathname;
|
|
282
|
+
|
|
283
|
+
// Stop observing this link
|
|
284
|
+
this.observer?.unobserve(link);
|
|
285
|
+
|
|
286
|
+
// Pre-render with visibility confidence
|
|
287
|
+
await this.prerender(route, 0.7);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private setupHoverListeners(): void {
|
|
292
|
+
document.addEventListener('mouseenter', (e) => this.onLinkHover(e), true);
|
|
293
|
+
document.addEventListener('mouseleave', (e) => this.onLinkLeave(e), true);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private onLinkHover(e: Event): void {
|
|
297
|
+
const link = (e.target as Element).closest(
|
|
298
|
+
'a[href^="/"]',
|
|
299
|
+
) as HTMLAnchorElement | null;
|
|
300
|
+
if (!link) return;
|
|
301
|
+
|
|
302
|
+
const route = new URL(link.href, window.location.origin).pathname;
|
|
303
|
+
|
|
304
|
+
// Set timeout to pre-render on sustained hover
|
|
305
|
+
const timeout = setTimeout(() => {
|
|
306
|
+
this.prerender(route, 0.9);
|
|
307
|
+
}, this.config.hoverDelay);
|
|
308
|
+
|
|
309
|
+
this.hoverTimeouts.set(route, timeout);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private onLinkLeave(e: Event): void {
|
|
313
|
+
const link = (e.target as Element).closest(
|
|
314
|
+
'a[href^="/"]',
|
|
315
|
+
) as HTMLAnchorElement | null;
|
|
316
|
+
if (!link) return;
|
|
317
|
+
|
|
318
|
+
const route = new URL(link.href, window.location.origin).pathname;
|
|
319
|
+
|
|
320
|
+
// Clear pending timeout
|
|
321
|
+
const timeout = this.hoverTimeouts.get(route);
|
|
322
|
+
if (timeout) {
|
|
323
|
+
clearTimeout(timeout);
|
|
324
|
+
this.hoverTimeouts.delete(route);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private injectSpeculationRules(): void {
|
|
329
|
+
// Check if browser supports Speculation Rules
|
|
330
|
+
if (
|
|
331
|
+
!(
|
|
332
|
+
'supports' in HTMLScriptElement &&
|
|
333
|
+
HTMLScriptElement.supports('speculationrules')
|
|
334
|
+
)
|
|
335
|
+
) {
|
|
336
|
+
console.log(
|
|
337
|
+
'[aeon:speculation] Browser does not support Speculation Rules API',
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const rules = {
|
|
343
|
+
prerender: [
|
|
344
|
+
{
|
|
345
|
+
source: 'document',
|
|
346
|
+
where: {
|
|
347
|
+
href_matches: '/*',
|
|
348
|
+
not: {
|
|
349
|
+
or: [
|
|
350
|
+
{ href_matches: '/api/*' },
|
|
351
|
+
{ href_matches: '/_aeon/*' },
|
|
352
|
+
{ selector_matches: '[data-aeon-no-prerender]' },
|
|
353
|
+
],
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
eagerness: 'moderate',
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const script = document.createElement('script');
|
|
362
|
+
script.type = 'speculationrules';
|
|
363
|
+
script.textContent = JSON.stringify(rules);
|
|
364
|
+
document.head.appendChild(script);
|
|
365
|
+
|
|
366
|
+
console.log('[aeon:speculation] Speculation Rules injected');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private setupNavigationInterception(): void {
|
|
370
|
+
// Intercept link clicks
|
|
371
|
+
document.addEventListener('click', async (e) => {
|
|
372
|
+
const link = (e.target as Element).closest(
|
|
373
|
+
'a[href^="/"]',
|
|
374
|
+
) as HTMLAnchorElement | null;
|
|
375
|
+
if (!link) return;
|
|
376
|
+
|
|
377
|
+
// Skip if modifier keys pressed
|
|
378
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
379
|
+
|
|
380
|
+
const route = new URL(link.href, window.location.origin).pathname;
|
|
381
|
+
|
|
382
|
+
// Try speculative navigation
|
|
383
|
+
if (await this.navigate(route)) {
|
|
384
|
+
e.preventDefault();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Handle popstate for back/forward
|
|
389
|
+
window.addEventListener('popstate', (e) => {
|
|
390
|
+
if (e.state?.aeonSpeculative) {
|
|
391
|
+
// This was a speculative navigation, handle accordingly
|
|
392
|
+
const route = window.location.pathname;
|
|
393
|
+
const cached = this.cache.get(route);
|
|
394
|
+
if (cached && !cached.stale) {
|
|
395
|
+
document.open();
|
|
396
|
+
document.write(cached.html);
|
|
397
|
+
document.close();
|
|
398
|
+
this.reinitialize();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async startPredictivePrerendering(): Promise<void> {
|
|
405
|
+
const predictor = getPredictor();
|
|
406
|
+
const currentRoute = window.location.pathname;
|
|
407
|
+
|
|
408
|
+
// Get predictions
|
|
409
|
+
const predictions = predictor.predict(currentRoute);
|
|
410
|
+
|
|
411
|
+
// Pre-render high-confidence predictions
|
|
412
|
+
for (const prediction of predictions) {
|
|
413
|
+
if (prediction.probability >= this.config.minConfidence) {
|
|
414
|
+
// Don't await - fire and forget
|
|
415
|
+
this.prerender(prediction.route, prediction.probability);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private reinitialize(): void {
|
|
421
|
+
// Re-observe links after DOM replacement
|
|
422
|
+
setTimeout(() => {
|
|
423
|
+
this.observeLinks();
|
|
424
|
+
this.startPredictivePrerendering();
|
|
425
|
+
}, 0);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private evictIfNeeded(incomingSize: number): void {
|
|
429
|
+
// Check if we need to evict
|
|
430
|
+
while (
|
|
431
|
+
(this.cache.size >= this.config.maxCachedPages ||
|
|
432
|
+
this.currentCacheSize + incomingSize > this.config.maxCacheSize) &&
|
|
433
|
+
this.cache.size > 0
|
|
434
|
+
) {
|
|
435
|
+
// Find oldest or lowest confidence page
|
|
436
|
+
let toEvict: string | null = null;
|
|
437
|
+
let lowestScore = Infinity;
|
|
438
|
+
|
|
439
|
+
for (const [route, page] of this.cache) {
|
|
440
|
+
// Score based on age and confidence
|
|
441
|
+
const age = Date.now() - page.prefetchedAt;
|
|
442
|
+
const score = page.confidence / (1 + age / 60000); // Decay over time
|
|
443
|
+
|
|
444
|
+
if (page.stale || score < lowestScore) {
|
|
445
|
+
lowestScore = score;
|
|
446
|
+
toEvict = route;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (toEvict) {
|
|
451
|
+
const page = this.cache.get(toEvict)!;
|
|
452
|
+
this.cache.delete(toEvict);
|
|
453
|
+
this.currentCacheSize -= page.size;
|
|
454
|
+
console.log(`[aeon:speculation] Evicted: ${toEvict}`);
|
|
455
|
+
} else {
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Singleton instance
|
|
463
|
+
let globalSpeculativeRenderer: SpeculativeRenderer | null = null;
|
|
464
|
+
|
|
465
|
+
export function getSpeculativeRenderer(): SpeculativeRenderer {
|
|
466
|
+
if (!globalSpeculativeRenderer) {
|
|
467
|
+
globalSpeculativeRenderer = new SpeculativeRenderer();
|
|
468
|
+
}
|
|
469
|
+
return globalSpeculativeRenderer;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function setSpeculativeRenderer(renderer: SpeculativeRenderer): void {
|
|
473
|
+
globalSpeculativeRenderer = renderer;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Initialize speculative rendering (call on page load)
|
|
478
|
+
*/
|
|
479
|
+
export function initSpeculativeRendering(
|
|
480
|
+
config?: Partial<SpeculativeRendererConfig>,
|
|
481
|
+
): SpeculativeRenderer {
|
|
482
|
+
const renderer = new SpeculativeRenderer(config);
|
|
483
|
+
setSpeculativeRenderer(renderer);
|
|
484
|
+
renderer.init();
|
|
485
|
+
return renderer;
|
|
486
|
+
}
|