@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,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Cache - In-memory cache for total preload navigation
|
|
3
|
+
*
|
|
4
|
+
* With 8.4KB framework + ~2-5KB per page session, we can preload EVERYTHING.
|
|
5
|
+
* Site with 100 pages = ~315KB total (smaller than one hero image!)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface CachedSession {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
route: string;
|
|
11
|
+
tree: unknown;
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
schemaVersion: string;
|
|
14
|
+
cachedAt: number;
|
|
15
|
+
expiresAt?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CacheStats {
|
|
19
|
+
size: number;
|
|
20
|
+
totalBytes: number;
|
|
21
|
+
hitRate: number;
|
|
22
|
+
preloadedRoutes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NavigationCacheOptions {
|
|
26
|
+
maxSize?: number;
|
|
27
|
+
defaultTtl?: number;
|
|
28
|
+
onEvict?: (session: CachedSession) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class NavigationCache {
|
|
32
|
+
private cache: Map<string, CachedSession> = new Map();
|
|
33
|
+
private accessOrder: string[] = [];
|
|
34
|
+
private hits = 0;
|
|
35
|
+
private misses = 0;
|
|
36
|
+
private maxSize: number;
|
|
37
|
+
private defaultTtl: number;
|
|
38
|
+
private onEvict?: (session: CachedSession) => void;
|
|
39
|
+
|
|
40
|
+
constructor(options: NavigationCacheOptions = {}) {
|
|
41
|
+
this.maxSize = options.maxSize ?? 1000;
|
|
42
|
+
this.defaultTtl = options.defaultTtl ?? 5 * 60 * 1000; // 5 minutes
|
|
43
|
+
this.onEvict = options.onEvict;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get a session from cache
|
|
48
|
+
*/
|
|
49
|
+
get(sessionId: string): CachedSession | null {
|
|
50
|
+
const session = this.cache.get(sessionId);
|
|
51
|
+
|
|
52
|
+
if (!session) {
|
|
53
|
+
this.misses++;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check expiration
|
|
58
|
+
if (session.expiresAt && Date.now() > session.expiresAt) {
|
|
59
|
+
this.cache.delete(sessionId);
|
|
60
|
+
this.removeFromAccessOrder(sessionId);
|
|
61
|
+
this.misses++;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.hits++;
|
|
66
|
+
this.updateAccessOrder(sessionId);
|
|
67
|
+
return session;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Store a session in cache
|
|
72
|
+
*/
|
|
73
|
+
set(session: CachedSession, ttl?: number): void {
|
|
74
|
+
const sessionId = session.sessionId;
|
|
75
|
+
|
|
76
|
+
// Evict if at capacity
|
|
77
|
+
if (!this.cache.has(sessionId) && this.cache.size >= this.maxSize) {
|
|
78
|
+
this.evictLRU();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const cached: CachedSession = {
|
|
82
|
+
...session,
|
|
83
|
+
cachedAt: Date.now(),
|
|
84
|
+
expiresAt: ttl ? Date.now() + ttl : Date.now() + this.defaultTtl,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
this.cache.set(sessionId, cached);
|
|
88
|
+
this.updateAccessOrder(sessionId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if session is cached
|
|
93
|
+
*/
|
|
94
|
+
has(sessionId: string): boolean {
|
|
95
|
+
const session = this.cache.get(sessionId);
|
|
96
|
+
if (!session) return false;
|
|
97
|
+
|
|
98
|
+
// Check expiration
|
|
99
|
+
if (session.expiresAt && Date.now() > session.expiresAt) {
|
|
100
|
+
this.cache.delete(sessionId);
|
|
101
|
+
this.removeFromAccessOrder(sessionId);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Prefetch a session by ID
|
|
110
|
+
*/
|
|
111
|
+
async prefetch(
|
|
112
|
+
sessionId: string,
|
|
113
|
+
fetcher: () => Promise<CachedSession>,
|
|
114
|
+
): Promise<CachedSession> {
|
|
115
|
+
// Return cached if available
|
|
116
|
+
const cached = this.get(sessionId);
|
|
117
|
+
if (cached) return cached;
|
|
118
|
+
|
|
119
|
+
// Fetch and cache
|
|
120
|
+
const session = await fetcher();
|
|
121
|
+
this.set(session);
|
|
122
|
+
return session;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Prefetch multiple sessions in parallel
|
|
127
|
+
*/
|
|
128
|
+
async prefetchMany(
|
|
129
|
+
sessionIds: string[],
|
|
130
|
+
fetcher: (sessionId: string) => Promise<CachedSession>,
|
|
131
|
+
): Promise<CachedSession[]> {
|
|
132
|
+
const promises = sessionIds.map(async (sessionId) => {
|
|
133
|
+
const cached = this.get(sessionId);
|
|
134
|
+
if (cached) return cached;
|
|
135
|
+
|
|
136
|
+
const session = await fetcher(sessionId);
|
|
137
|
+
this.set(session);
|
|
138
|
+
return session;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return Promise.all(promises);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Preload all sessions (total preload strategy)
|
|
146
|
+
*/
|
|
147
|
+
async preloadAll(
|
|
148
|
+
manifest: { sessionId: string; route: string }[],
|
|
149
|
+
fetcher: (sessionId: string) => Promise<CachedSession>,
|
|
150
|
+
options: { onProgress?: (loaded: number, total: number) => void } = {},
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
const total = manifest.length;
|
|
153
|
+
let loaded = 0;
|
|
154
|
+
|
|
155
|
+
// Use batching to avoid overwhelming the network
|
|
156
|
+
const batchSize = 10;
|
|
157
|
+
for (let i = 0; i < manifest.length; i += batchSize) {
|
|
158
|
+
const batch = manifest.slice(i, i + batchSize);
|
|
159
|
+
|
|
160
|
+
await Promise.all(
|
|
161
|
+
batch.map(async ({ sessionId }) => {
|
|
162
|
+
if (!this.has(sessionId)) {
|
|
163
|
+
try {
|
|
164
|
+
const session = await fetcher(sessionId);
|
|
165
|
+
this.set(session, Infinity); // Never expire for total preload
|
|
166
|
+
} catch {
|
|
167
|
+
// Ignore failed fetches during preload
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
loaded++;
|
|
171
|
+
options.onProgress?.(loaded, total);
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Small delay between batches to keep main thread responsive
|
|
176
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Invalidate a cached session
|
|
182
|
+
*/
|
|
183
|
+
invalidate(sessionId: string): void {
|
|
184
|
+
const session = this.cache.get(sessionId);
|
|
185
|
+
if (session && this.onEvict) {
|
|
186
|
+
this.onEvict(session);
|
|
187
|
+
}
|
|
188
|
+
this.cache.delete(sessionId);
|
|
189
|
+
this.removeFromAccessOrder(sessionId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Clear all cached sessions
|
|
194
|
+
*/
|
|
195
|
+
clear(): void {
|
|
196
|
+
if (this.onEvict) {
|
|
197
|
+
for (const session of this.cache.values()) {
|
|
198
|
+
this.onEvict(session);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
this.cache.clear();
|
|
202
|
+
this.accessOrder = [];
|
|
203
|
+
this.hits = 0;
|
|
204
|
+
this.misses = 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get cache statistics
|
|
209
|
+
*/
|
|
210
|
+
getStats(): CacheStats {
|
|
211
|
+
let totalBytes = 0;
|
|
212
|
+
for (const session of this.cache.values()) {
|
|
213
|
+
totalBytes += JSON.stringify(session).length;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const total = this.hits + this.misses;
|
|
217
|
+
return {
|
|
218
|
+
size: this.cache.size,
|
|
219
|
+
totalBytes,
|
|
220
|
+
hitRate: total > 0 ? this.hits / total : 0,
|
|
221
|
+
preloadedRoutes: this.cache.size,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get all cached session IDs
|
|
227
|
+
*/
|
|
228
|
+
keys(): string[] {
|
|
229
|
+
return Array.from(this.cache.keys());
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Export cache for service worker storage
|
|
234
|
+
*/
|
|
235
|
+
export(): CachedSession[] {
|
|
236
|
+
return Array.from(this.cache.values());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Import cache from service worker storage
|
|
241
|
+
*/
|
|
242
|
+
import(sessions: CachedSession[]): void {
|
|
243
|
+
for (const session of sessions) {
|
|
244
|
+
this.set(session);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// LRU eviction
|
|
249
|
+
private evictLRU(): void {
|
|
250
|
+
if (this.accessOrder.length === 0) return;
|
|
251
|
+
|
|
252
|
+
const lruId = this.accessOrder.shift()!;
|
|
253
|
+
const session = this.cache.get(lruId);
|
|
254
|
+
|
|
255
|
+
if (session && this.onEvict) {
|
|
256
|
+
this.onEvict(session);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.cache.delete(lruId);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private updateAccessOrder(sessionId: string): void {
|
|
263
|
+
this.removeFromAccessOrder(sessionId);
|
|
264
|
+
this.accessOrder.push(sessionId);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private removeFromAccessOrder(sessionId: string): void {
|
|
268
|
+
const index = this.accessOrder.indexOf(sessionId);
|
|
269
|
+
if (index !== -1) {
|
|
270
|
+
this.accessOrder.splice(index, 1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Singleton instance for global access
|
|
276
|
+
let globalCache: NavigationCache | null = null;
|
|
277
|
+
|
|
278
|
+
export function getNavigationCache(): NavigationCache {
|
|
279
|
+
if (!globalCache) {
|
|
280
|
+
globalCache = new NavigationCache();
|
|
281
|
+
}
|
|
282
|
+
return globalCache;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function setNavigationCache(cache: NavigationCache): void {
|
|
286
|
+
globalCache = cache;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// =============================================================================
|
|
290
|
+
// SKELETON CACHE - Separate fast cache for skeleton-first rendering
|
|
291
|
+
// =============================================================================
|
|
292
|
+
|
|
293
|
+
/** Cached skeleton data for a route */
|
|
294
|
+
export interface CachedSkeleton {
|
|
295
|
+
route: string;
|
|
296
|
+
html: string;
|
|
297
|
+
css: string;
|
|
298
|
+
cachedAt: number;
|
|
299
|
+
expiresAt?: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Skeleton cache options */
|
|
303
|
+
export interface SkeletonCacheOptions {
|
|
304
|
+
/** Maximum number of skeletons to cache */
|
|
305
|
+
maxSize?: number;
|
|
306
|
+
/** Default TTL in milliseconds */
|
|
307
|
+
defaultTtl?: number;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Skeleton Cache - Optimized for instant skeleton delivery
|
|
312
|
+
*
|
|
313
|
+
* Skeletons are cached separately from full pages for faster access.
|
|
314
|
+
* In edge environments, skeletons are stored in KV for ~1ms access.
|
|
315
|
+
*/
|
|
316
|
+
export class SkeletonCache {
|
|
317
|
+
private cache: Map<string, CachedSkeleton> = new Map();
|
|
318
|
+
private maxSize: number;
|
|
319
|
+
private defaultTtl: number;
|
|
320
|
+
|
|
321
|
+
constructor(options: SkeletonCacheOptions = {}) {
|
|
322
|
+
this.maxSize = options.maxSize ?? 500;
|
|
323
|
+
this.defaultTtl = options.defaultTtl ?? 30 * 60 * 1000; // 30 minutes
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Get skeleton for a route
|
|
328
|
+
*/
|
|
329
|
+
get(route: string): CachedSkeleton | null {
|
|
330
|
+
const skeleton = this.cache.get(route);
|
|
331
|
+
|
|
332
|
+
if (!skeleton) return null;
|
|
333
|
+
|
|
334
|
+
// Check expiration
|
|
335
|
+
if (skeleton.expiresAt && Date.now() > skeleton.expiresAt) {
|
|
336
|
+
this.cache.delete(route);
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return skeleton;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Store skeleton for a route
|
|
345
|
+
*/
|
|
346
|
+
set(skeleton: CachedSkeleton, ttl?: number): void {
|
|
347
|
+
// Evict oldest if at capacity
|
|
348
|
+
if (!this.cache.has(skeleton.route) && this.cache.size >= this.maxSize) {
|
|
349
|
+
const oldest = this.cache.keys().next().value;
|
|
350
|
+
if (oldest) this.cache.delete(oldest);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this.cache.set(skeleton.route, {
|
|
354
|
+
...skeleton,
|
|
355
|
+
cachedAt: Date.now(),
|
|
356
|
+
expiresAt: ttl ? Date.now() + ttl : Date.now() + this.defaultTtl,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Check if skeleton is cached
|
|
362
|
+
*/
|
|
363
|
+
has(route: string): boolean {
|
|
364
|
+
const skeleton = this.cache.get(route);
|
|
365
|
+
if (!skeleton) return false;
|
|
366
|
+
|
|
367
|
+
if (skeleton.expiresAt && Date.now() > skeleton.expiresAt) {
|
|
368
|
+
this.cache.delete(route);
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Invalidate skeleton for a route
|
|
377
|
+
*/
|
|
378
|
+
invalidate(route: string): void {
|
|
379
|
+
this.cache.delete(route);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Clear all cached skeletons
|
|
384
|
+
*/
|
|
385
|
+
clear(): void {
|
|
386
|
+
this.cache.clear();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Get cache size
|
|
391
|
+
*/
|
|
392
|
+
get size(): number {
|
|
393
|
+
return this.cache.size;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Export all skeletons for service worker
|
|
398
|
+
*/
|
|
399
|
+
export(): CachedSkeleton[] {
|
|
400
|
+
return Array.from(this.cache.values());
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Import skeletons from service worker
|
|
405
|
+
*/
|
|
406
|
+
import(skeletons: CachedSkeleton[]): void {
|
|
407
|
+
for (const skeleton of skeletons) {
|
|
408
|
+
this.set(skeleton);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Skeleton and content result for progressive rendering */
|
|
414
|
+
export interface SkeletonWithContent {
|
|
415
|
+
/** Skeleton HTML (available immediately) */
|
|
416
|
+
skeleton: CachedSkeleton | null;
|
|
417
|
+
/** Content promise (resolves later) */
|
|
418
|
+
content: Promise<CachedSession | null>;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Get skeleton and content in parallel for optimal UX
|
|
423
|
+
*
|
|
424
|
+
* @param route - The route to fetch
|
|
425
|
+
* @param skeletonCache - Skeleton cache instance
|
|
426
|
+
* @param sessionCache - Session cache instance
|
|
427
|
+
* @param contentFetcher - Function to fetch full content
|
|
428
|
+
*/
|
|
429
|
+
export function getWithSkeleton(
|
|
430
|
+
route: string,
|
|
431
|
+
skeletonCache: SkeletonCache,
|
|
432
|
+
sessionCache: NavigationCache,
|
|
433
|
+
contentFetcher: (route: string) => Promise<CachedSession>,
|
|
434
|
+
): SkeletonWithContent {
|
|
435
|
+
// Get skeleton immediately (sync)
|
|
436
|
+
const skeleton = skeletonCache.get(route);
|
|
437
|
+
|
|
438
|
+
// Start content fetch in parallel
|
|
439
|
+
const content = (async () => {
|
|
440
|
+
// Check session cache first
|
|
441
|
+
const sessionId = routeToSessionId(route);
|
|
442
|
+
const cached = sessionCache.get(sessionId);
|
|
443
|
+
if (cached) return cached;
|
|
444
|
+
|
|
445
|
+
// Fetch from network
|
|
446
|
+
try {
|
|
447
|
+
const session = await contentFetcher(route);
|
|
448
|
+
sessionCache.set(session);
|
|
449
|
+
return session;
|
|
450
|
+
} catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
})();
|
|
454
|
+
|
|
455
|
+
return { skeleton, content };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Convert route to session ID
|
|
460
|
+
* This is a simple implementation - real apps may have more complex mapping
|
|
461
|
+
*/
|
|
462
|
+
function routeToSessionId(route: string): string {
|
|
463
|
+
// Remove leading/trailing slashes and replace slashes with dashes
|
|
464
|
+
return route.replace(/^\/|\/$/g, '').replace(/\//g, '-') || 'index';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Singleton skeleton cache
|
|
468
|
+
let globalSkeletonCache: SkeletonCache | null = null;
|
|
469
|
+
|
|
470
|
+
export function getSkeletonCache(): SkeletonCache {
|
|
471
|
+
if (!globalSkeletonCache) {
|
|
472
|
+
globalSkeletonCache = new SkeletonCache();
|
|
473
|
+
}
|
|
474
|
+
return globalSkeletonCache;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function setSkeletonCache(cache: SkeletonCache): void {
|
|
478
|
+
globalSkeletonCache = cache;
|
|
479
|
+
}
|