@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,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aeon Navigation Engine
|
|
3
|
+
*
|
|
4
|
+
* Cutting-edge navigation with:
|
|
5
|
+
* - Speculative prefetching
|
|
6
|
+
* - Total preload capability
|
|
7
|
+
* - View transitions
|
|
8
|
+
* - Presence awareness
|
|
9
|
+
* - Predictive navigation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { AeonRouter } from './router.js';
|
|
13
|
+
import type { RouteMatch } from './types';
|
|
14
|
+
import {
|
|
15
|
+
NavigationCache,
|
|
16
|
+
type CachedSession,
|
|
17
|
+
getNavigationCache,
|
|
18
|
+
} from './cache';
|
|
19
|
+
|
|
20
|
+
export interface NavigationOptions {
|
|
21
|
+
transition?: 'slide' | 'fade' | 'morph' | 'none';
|
|
22
|
+
replace?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PrefetchOptions {
|
|
26
|
+
data?: boolean;
|
|
27
|
+
presence?: boolean;
|
|
28
|
+
priority?: 'high' | 'normal' | 'low';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface NavigationState {
|
|
32
|
+
current: string;
|
|
33
|
+
previous: string | null;
|
|
34
|
+
history: string[];
|
|
35
|
+
isNavigating: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PresenceInfo {
|
|
39
|
+
route: string;
|
|
40
|
+
count: number;
|
|
41
|
+
editing: number;
|
|
42
|
+
hot: boolean;
|
|
43
|
+
users?: { userId: string; name?: string }[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PredictedRoute {
|
|
47
|
+
route: string;
|
|
48
|
+
probability: number;
|
|
49
|
+
reason: 'history' | 'hover' | 'visibility' | 'community';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type NavigationListener = (state: NavigationState) => void;
|
|
53
|
+
type PresenceListener = (route: string, presence: PresenceInfo) => void;
|
|
54
|
+
|
|
55
|
+
export class AeonNavigationEngine {
|
|
56
|
+
private router: AeonRouter;
|
|
57
|
+
private cache: NavigationCache;
|
|
58
|
+
private state: NavigationState;
|
|
59
|
+
private navigationListeners: Set<NavigationListener> = new Set();
|
|
60
|
+
private presenceListeners: Set<PresenceListener> = new Set();
|
|
61
|
+
private presenceCache: Map<string, PresenceInfo> = new Map();
|
|
62
|
+
private navigationHistory: Map<string, Map<string, number>> = new Map();
|
|
63
|
+
private pendingPrefetches: Map<string, Promise<CachedSession>> = new Map();
|
|
64
|
+
private observer: IntersectionObserver | null = null;
|
|
65
|
+
private sessionFetcher?: (sessionId: string) => Promise<CachedSession>;
|
|
66
|
+
private presenceFetcher?: (route: string) => Promise<PresenceInfo>;
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
options: {
|
|
70
|
+
router?: AeonRouter;
|
|
71
|
+
cache?: NavigationCache;
|
|
72
|
+
initialRoute?: string;
|
|
73
|
+
sessionFetcher?: (sessionId: string) => Promise<CachedSession>;
|
|
74
|
+
presenceFetcher?: (route: string) => Promise<PresenceInfo>;
|
|
75
|
+
} = {},
|
|
76
|
+
) {
|
|
77
|
+
this.router = options.router ?? new AeonRouter({ routesDir: './pages' });
|
|
78
|
+
this.cache = options.cache ?? getNavigationCache();
|
|
79
|
+
this.sessionFetcher = options.sessionFetcher;
|
|
80
|
+
this.presenceFetcher = options.presenceFetcher;
|
|
81
|
+
|
|
82
|
+
this.state = {
|
|
83
|
+
current: options.initialRoute ?? '/',
|
|
84
|
+
previous: null,
|
|
85
|
+
history: [options.initialRoute ?? '/'],
|
|
86
|
+
isNavigating: false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Navigate to a route with optional view transition
|
|
92
|
+
*/
|
|
93
|
+
async navigate(href: string, options: NavigationOptions = {}): Promise<void> {
|
|
94
|
+
const { transition = 'fade', replace = false } = options;
|
|
95
|
+
|
|
96
|
+
// Match route
|
|
97
|
+
const match = this.router.match(href);
|
|
98
|
+
if (!match) {
|
|
99
|
+
throw new Error(`Route not found: ${href}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Update state
|
|
103
|
+
const previousRoute = this.state.current;
|
|
104
|
+
this.state.isNavigating = true;
|
|
105
|
+
this.notifyListeners();
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Get session (from cache or fetch)
|
|
109
|
+
const session = await this.getSession(match.sessionId);
|
|
110
|
+
|
|
111
|
+
// Perform navigation with view transition
|
|
112
|
+
if (
|
|
113
|
+
transition !== 'none' &&
|
|
114
|
+
typeof document !== 'undefined' &&
|
|
115
|
+
'startViewTransition' in document
|
|
116
|
+
) {
|
|
117
|
+
await (document as any).startViewTransition(() => {
|
|
118
|
+
this.updateDOM(session, match);
|
|
119
|
+
}).finished;
|
|
120
|
+
} else {
|
|
121
|
+
this.updateDOM(session, match);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Update state
|
|
125
|
+
this.state.previous = previousRoute;
|
|
126
|
+
this.state.current = href;
|
|
127
|
+
if (!replace) {
|
|
128
|
+
this.state.history.push(href);
|
|
129
|
+
} else {
|
|
130
|
+
this.state.history[this.state.history.length - 1] = href;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Update browser history
|
|
134
|
+
if (typeof window !== 'undefined') {
|
|
135
|
+
if (replace) {
|
|
136
|
+
window.history.replaceState({ route: href }, '', href);
|
|
137
|
+
} else {
|
|
138
|
+
window.history.pushState({ route: href }, '', href);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Record for prediction
|
|
143
|
+
this.recordNavigation(previousRoute, href);
|
|
144
|
+
|
|
145
|
+
// Prefetch predicted next routes
|
|
146
|
+
const predictions = this.predict(href);
|
|
147
|
+
for (const prediction of predictions.slice(0, 3)) {
|
|
148
|
+
if (prediction.probability > 0.3) {
|
|
149
|
+
this.prefetch(prediction.route);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
this.state.isNavigating = false;
|
|
154
|
+
this.notifyListeners();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Prefetch a route
|
|
160
|
+
*/
|
|
161
|
+
async prefetch(href: string, options: PrefetchOptions = {}): Promise<void> {
|
|
162
|
+
const { data = true, presence = false, priority = 'normal' } = options;
|
|
163
|
+
|
|
164
|
+
const match = this.router.match(href);
|
|
165
|
+
if (!match) return;
|
|
166
|
+
|
|
167
|
+
// Avoid duplicate prefetches
|
|
168
|
+
const cacheKey = `${match.sessionId}:${data}:${presence}`;
|
|
169
|
+
if (this.pendingPrefetches.has(cacheKey)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const prefetchPromise = (async () => {
|
|
174
|
+
const promises: Promise<unknown>[] = [];
|
|
175
|
+
|
|
176
|
+
// Prefetch session data
|
|
177
|
+
if (data && this.sessionFetcher) {
|
|
178
|
+
promises.push(
|
|
179
|
+
this.cache.prefetch(match.sessionId, () =>
|
|
180
|
+
this.sessionFetcher!(match.sessionId),
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Prefetch presence
|
|
186
|
+
if (presence && this.presenceFetcher) {
|
|
187
|
+
promises.push(this.prefetchPresence(href));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await Promise.all(promises);
|
|
191
|
+
return this.cache.get(match.sessionId)!;
|
|
192
|
+
})();
|
|
193
|
+
|
|
194
|
+
this.pendingPrefetches.set(cacheKey, prefetchPromise);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await prefetchPromise;
|
|
198
|
+
} finally {
|
|
199
|
+
this.pendingPrefetches.delete(cacheKey);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Prefetch presence for a route
|
|
205
|
+
*/
|
|
206
|
+
async prefetchPresence(route: string): Promise<PresenceInfo | null> {
|
|
207
|
+
if (!this.presenceFetcher) return null;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const presence = await this.presenceFetcher(route);
|
|
211
|
+
this.presenceCache.set(route, presence);
|
|
212
|
+
this.notifyPresenceListeners(route, presence);
|
|
213
|
+
return presence;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Check if a route is preloaded
|
|
221
|
+
*/
|
|
222
|
+
isPreloaded(href: string): boolean {
|
|
223
|
+
const match = this.router.match(href);
|
|
224
|
+
if (!match) return false;
|
|
225
|
+
return this.cache.has(match.sessionId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Get cached presence for a route
|
|
230
|
+
*/
|
|
231
|
+
getPresence(route: string): PresenceInfo | null {
|
|
232
|
+
return this.presenceCache.get(route) ?? null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Observe links for visibility-based prefetch
|
|
237
|
+
*/
|
|
238
|
+
observeLinks(container: Element): () => void {
|
|
239
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
240
|
+
return () => {};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
this.observer = new IntersectionObserver(
|
|
244
|
+
(entries) => {
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
if (entry.isIntersecting) {
|
|
247
|
+
const link = entry.target as HTMLAnchorElement;
|
|
248
|
+
const href = link.getAttribute('href');
|
|
249
|
+
if (href && href.startsWith('/')) {
|
|
250
|
+
this.prefetch(href);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{ rootMargin: '100px' },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const links = container.querySelectorAll('a[href^="/"]');
|
|
259
|
+
links.forEach((link) => this.observer!.observe(link));
|
|
260
|
+
|
|
261
|
+
return () => {
|
|
262
|
+
this.observer?.disconnect();
|
|
263
|
+
this.observer = null;
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Predict next navigation destinations
|
|
269
|
+
*/
|
|
270
|
+
predict(currentRoute: string): PredictedRoute[] {
|
|
271
|
+
const predictions: PredictedRoute[] = [];
|
|
272
|
+
|
|
273
|
+
// From navigation history
|
|
274
|
+
const fromHistory = this.navigationHistory.get(currentRoute);
|
|
275
|
+
if (fromHistory) {
|
|
276
|
+
const total = Array.from(fromHistory.values()).reduce((a, b) => a + b, 0);
|
|
277
|
+
for (const [route, count] of fromHistory) {
|
|
278
|
+
predictions.push({
|
|
279
|
+
route,
|
|
280
|
+
probability: count / total,
|
|
281
|
+
reason: 'history',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Sort by probability
|
|
287
|
+
predictions.sort((a, b) => b.probability - a.probability);
|
|
288
|
+
|
|
289
|
+
return predictions;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Go back in navigation history
|
|
294
|
+
*/
|
|
295
|
+
async back(): Promise<void> {
|
|
296
|
+
if (this.state.history.length <= 1) return;
|
|
297
|
+
|
|
298
|
+
this.state.history.pop();
|
|
299
|
+
const previousRoute = this.state.history[this.state.history.length - 1];
|
|
300
|
+
|
|
301
|
+
await this.navigate(previousRoute, { replace: true });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Get current navigation state
|
|
306
|
+
*/
|
|
307
|
+
getState(): NavigationState {
|
|
308
|
+
return { ...this.state };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Subscribe to navigation changes
|
|
313
|
+
*/
|
|
314
|
+
subscribe(listener: NavigationListener): () => void {
|
|
315
|
+
this.navigationListeners.add(listener);
|
|
316
|
+
return () => this.navigationListeners.delete(listener);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Subscribe to presence changes for a route
|
|
321
|
+
*/
|
|
322
|
+
subscribePresence(listener: PresenceListener): () => void {
|
|
323
|
+
this.presenceListeners.add(listener);
|
|
324
|
+
return () => this.presenceListeners.delete(listener);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Preload all routes (total preload strategy)
|
|
329
|
+
*/
|
|
330
|
+
async preloadAll(
|
|
331
|
+
onProgress?: (loaded: number, total: number) => void,
|
|
332
|
+
): Promise<void> {
|
|
333
|
+
if (!this.sessionFetcher) {
|
|
334
|
+
throw new Error('sessionFetcher required for preloadAll');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const routes = this.router.getRoutes();
|
|
338
|
+
const manifest = routes.map((r) => ({
|
|
339
|
+
sessionId: this.router.match(r.pattern)?.sessionId ?? r.pattern,
|
|
340
|
+
route: r.pattern,
|
|
341
|
+
}));
|
|
342
|
+
|
|
343
|
+
await this.cache.preloadAll(manifest, this.sessionFetcher, { onProgress });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get cache statistics
|
|
348
|
+
*/
|
|
349
|
+
getCacheStats() {
|
|
350
|
+
return this.cache.getStats();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Private methods
|
|
354
|
+
|
|
355
|
+
private async getSession(sessionId: string): Promise<CachedSession> {
|
|
356
|
+
const cached = this.cache.get(sessionId);
|
|
357
|
+
if (cached) return cached;
|
|
358
|
+
|
|
359
|
+
if (!this.sessionFetcher) {
|
|
360
|
+
throw new Error('Session not cached and no fetcher provided');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const session = await this.sessionFetcher(sessionId);
|
|
364
|
+
this.cache.set(session);
|
|
365
|
+
return session;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private updateDOM(session: CachedSession, match: RouteMatch): void {
|
|
369
|
+
// This is a placeholder - actual implementation would render the page
|
|
370
|
+
// In practice, this integrates with React/the rendering layer
|
|
371
|
+
if (typeof document !== 'undefined') {
|
|
372
|
+
// Dispatch custom event for React integration
|
|
373
|
+
const event = new CustomEvent('aeon:navigate', {
|
|
374
|
+
detail: { session, match },
|
|
375
|
+
});
|
|
376
|
+
document.dispatchEvent(event);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private recordNavigation(from: string, to: string): void {
|
|
381
|
+
if (!this.navigationHistory.has(from)) {
|
|
382
|
+
this.navigationHistory.set(from, new Map());
|
|
383
|
+
}
|
|
384
|
+
const fromMap = this.navigationHistory.get(from)!;
|
|
385
|
+
fromMap.set(to, (fromMap.get(to) ?? 0) + 1);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private notifyListeners(): void {
|
|
389
|
+
for (const listener of this.navigationListeners) {
|
|
390
|
+
listener(this.getState());
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private notifyPresenceListeners(route: string, presence: PresenceInfo): void {
|
|
395
|
+
for (const listener of this.presenceListeners) {
|
|
396
|
+
listener(route, presence);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Singleton instance
|
|
402
|
+
let globalNavigator: AeonNavigationEngine | null = null;
|
|
403
|
+
|
|
404
|
+
export function getNavigator(): AeonNavigationEngine {
|
|
405
|
+
if (!globalNavigator) {
|
|
406
|
+
globalNavigator = new AeonNavigationEngine();
|
|
407
|
+
}
|
|
408
|
+
return globalNavigator;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function setNavigator(navigator: AeonNavigationEngine): void {
|
|
412
|
+
globalNavigator = navigator;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Browser history integration
|
|
416
|
+
if (typeof window !== 'undefined') {
|
|
417
|
+
window.addEventListener('popstate', (event) => {
|
|
418
|
+
const navigator = getNavigator();
|
|
419
|
+
const route = event.state?.route ?? window.location.pathname;
|
|
420
|
+
navigator.navigate(route, { replace: true });
|
|
421
|
+
});
|
|
422
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js Route Adapter
|
|
3
|
+
*
|
|
4
|
+
* Adapts Next.js App Router API routes to run on Cloudflare Workers.
|
|
5
|
+
* This enables "one is all" - Aeon serves both pages AND API routes.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { createAeonApp } from '@affectively/aeon-pages-runtime';
|
|
10
|
+
*
|
|
11
|
+
* // Automatically load all routes from Next.js app/api directory
|
|
12
|
+
* export default createAeonApp({
|
|
13
|
+
* // Pages from Aeon
|
|
14
|
+
* pagesDir: './pages',
|
|
15
|
+
*
|
|
16
|
+
* // API routes from Next.js (or local)
|
|
17
|
+
* apiRoutes: await loadNextjsRoutes('../web-app/src/app/api'),
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { AeonEnv, AeonContext, ExecutionContext } from './types';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// TYPES
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/** Next.js style request with nextUrl */
|
|
29
|
+
export interface NextRequest extends Request {
|
|
30
|
+
nextUrl: URL;
|
|
31
|
+
cookies: {
|
|
32
|
+
get(name: string): { value: string } | undefined;
|
|
33
|
+
getAll(): Array<{ name: string; value: string }>;
|
|
34
|
+
};
|
|
35
|
+
headers: Headers;
|
|
36
|
+
geo?: { city?: string; country?: string; region?: string };
|
|
37
|
+
ip?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Next.js style response */
|
|
41
|
+
export interface NextResponse extends Response {
|
|
42
|
+
cookies: {
|
|
43
|
+
set(
|
|
44
|
+
name: string,
|
|
45
|
+
value: string,
|
|
46
|
+
options?: { path?: string; maxAge?: number; httpOnly?: boolean },
|
|
47
|
+
): void;
|
|
48
|
+
delete(name: string): void;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Next.js route handler signature */
|
|
53
|
+
export type NextRouteHandler = (
|
|
54
|
+
request: NextRequest,
|
|
55
|
+
context?: { params: Record<string, string | string[]> },
|
|
56
|
+
) => Response | Promise<Response>;
|
|
57
|
+
|
|
58
|
+
/** Next.js route module */
|
|
59
|
+
export interface NextRouteModule {
|
|
60
|
+
GET?: NextRouteHandler;
|
|
61
|
+
POST?: NextRouteHandler;
|
|
62
|
+
PUT?: NextRouteHandler;
|
|
63
|
+
PATCH?: NextRouteHandler;
|
|
64
|
+
DELETE?: NextRouteHandler;
|
|
65
|
+
HEAD?: NextRouteHandler;
|
|
66
|
+
OPTIONS?: NextRouteHandler;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// REQUEST ADAPTER
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Adapt a standard Request to Next.js style NextRequest
|
|
75
|
+
*/
|
|
76
|
+
export function adaptRequest(
|
|
77
|
+
request: Request,
|
|
78
|
+
params: Record<string, string>,
|
|
79
|
+
): NextRequest {
|
|
80
|
+
const url = new URL(request.url);
|
|
81
|
+
|
|
82
|
+
// Create cookies accessor
|
|
83
|
+
const cookieHeader = request.headers.get('Cookie') || '';
|
|
84
|
+
const cookies = parseCookies(cookieHeader);
|
|
85
|
+
|
|
86
|
+
const nextRequest = request as NextRequest;
|
|
87
|
+
|
|
88
|
+
// Add nextUrl
|
|
89
|
+
Object.defineProperty(nextRequest, 'nextUrl', {
|
|
90
|
+
value: url,
|
|
91
|
+
writable: false,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Add cookies accessor
|
|
95
|
+
Object.defineProperty(nextRequest, 'cookies', {
|
|
96
|
+
value: {
|
|
97
|
+
get(name: string) {
|
|
98
|
+
const value = cookies[name];
|
|
99
|
+
return value ? { value } : undefined;
|
|
100
|
+
},
|
|
101
|
+
getAll() {
|
|
102
|
+
return Object.entries(cookies).map(([name, value]) => ({
|
|
103
|
+
name,
|
|
104
|
+
value,
|
|
105
|
+
}));
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
writable: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Add Cloudflare-specific properties
|
|
112
|
+
const cfProps = (request as unknown as { cf?: Record<string, unknown> }).cf;
|
|
113
|
+
if (cfProps) {
|
|
114
|
+
Object.defineProperty(nextRequest, 'geo', {
|
|
115
|
+
value: {
|
|
116
|
+
city: cfProps.city as string,
|
|
117
|
+
country: cfProps.country as string,
|
|
118
|
+
region: cfProps.region as string,
|
|
119
|
+
},
|
|
120
|
+
writable: false,
|
|
121
|
+
});
|
|
122
|
+
Object.defineProperty(nextRequest, 'ip', {
|
|
123
|
+
value: request.headers.get('CF-Connecting-IP') || undefined,
|
|
124
|
+
writable: false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return nextRequest;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseCookies(cookieHeader: string): Record<string, string> {
|
|
132
|
+
const cookies: Record<string, string> = {};
|
|
133
|
+
if (!cookieHeader) return cookies;
|
|
134
|
+
|
|
135
|
+
cookieHeader.split(';').forEach((cookie) => {
|
|
136
|
+
const [name, ...valueParts] = cookie.trim().split('=');
|
|
137
|
+
if (name) {
|
|
138
|
+
cookies[name] = valueParts.join('=');
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return cookies;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// ROUTE HANDLER ADAPTER
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Wrap a Next.js route handler to work with Aeon context
|
|
151
|
+
*/
|
|
152
|
+
export function adaptHandler<E extends AeonEnv = AeonEnv>(
|
|
153
|
+
handler: NextRouteHandler,
|
|
154
|
+
): (ctx: AeonContext<E>) => Promise<Response> {
|
|
155
|
+
return async (ctx: AeonContext<E>): Promise<Response> => {
|
|
156
|
+
const nextRequest = adaptRequest(ctx.request, ctx.params);
|
|
157
|
+
|
|
158
|
+
// Convert params to Next.js format (string | string[])
|
|
159
|
+
const nextParams: Record<string, string | string[]> = {};
|
|
160
|
+
for (const [key, value] of Object.entries(ctx.params)) {
|
|
161
|
+
// Handle catch-all routes
|
|
162
|
+
if (value.includes('/')) {
|
|
163
|
+
nextParams[key] = value.split('/');
|
|
164
|
+
} else {
|
|
165
|
+
nextParams[key] = value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const response = await handler(nextRequest, { params: nextParams });
|
|
171
|
+
return response;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('Next.js route handler error:', error);
|
|
174
|
+
return new Response(
|
|
175
|
+
JSON.stringify({
|
|
176
|
+
error: 'Internal server error',
|
|
177
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
178
|
+
}),
|
|
179
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Adapt an entire Next.js route module
|
|
187
|
+
*/
|
|
188
|
+
export function adaptRouteModule<E extends AeonEnv = AeonEnv>(
|
|
189
|
+
module: NextRouteModule,
|
|
190
|
+
): Record<string, (ctx: AeonContext<E>) => Promise<Response>> {
|
|
191
|
+
const adapted: Record<string, (ctx: AeonContext<E>) => Promise<Response>> =
|
|
192
|
+
{};
|
|
193
|
+
|
|
194
|
+
const methods: (keyof NextRouteModule)[] = [
|
|
195
|
+
'GET',
|
|
196
|
+
'POST',
|
|
197
|
+
'PUT',
|
|
198
|
+
'PATCH',
|
|
199
|
+
'DELETE',
|
|
200
|
+
'HEAD',
|
|
201
|
+
'OPTIONS',
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
for (const method of methods) {
|
|
205
|
+
const handler = module[method];
|
|
206
|
+
if (handler) {
|
|
207
|
+
adapted[method] = adaptHandler(handler);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return adapted;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// =============================================================================
|
|
215
|
+
// NEXT.JS RESPONSE HELPERS
|
|
216
|
+
// =============================================================================
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Create a NextResponse.json() compatible response
|
|
220
|
+
*/
|
|
221
|
+
export function json<T>(data: T, init?: ResponseInit): Response {
|
|
222
|
+
return new Response(JSON.stringify(data), {
|
|
223
|
+
...init,
|
|
224
|
+
headers: {
|
|
225
|
+
'Content-Type': 'application/json',
|
|
226
|
+
...init?.headers,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Create a NextResponse.redirect() compatible response
|
|
233
|
+
*/
|
|
234
|
+
export function redirect(
|
|
235
|
+
url: string | URL,
|
|
236
|
+
status: 301 | 302 | 303 | 307 | 308 = 307,
|
|
237
|
+
): Response {
|
|
238
|
+
return new Response(null, {
|
|
239
|
+
status,
|
|
240
|
+
headers: { Location: url.toString() },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create a NextResponse.rewrite() compatible response
|
|
246
|
+
* Note: In Workers, this is just a redirect
|
|
247
|
+
*/
|
|
248
|
+
export function rewrite(url: string | URL): Response {
|
|
249
|
+
return new Response(null, {
|
|
250
|
+
status: 307,
|
|
251
|
+
headers: { Location: url.toString() },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Create a NextResponse.next() compatible response
|
|
257
|
+
* Used in middleware to continue to the next handler
|
|
258
|
+
*/
|
|
259
|
+
export function next(): Response {
|
|
260
|
+
return new Response(null, {
|
|
261
|
+
status: 200,
|
|
262
|
+
headers: { 'x-middleware-next': '1' },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Export as NextResponse-like object
|
|
267
|
+
export const NextResponse = {
|
|
268
|
+
json,
|
|
269
|
+
redirect,
|
|
270
|
+
rewrite,
|
|
271
|
+
next,
|
|
272
|
+
};
|