@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,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Context Extractor
|
|
3
|
+
*
|
|
4
|
+
* Middleware that extracts UserContext from HTTP requests.
|
|
5
|
+
* Gathers signals from headers, cookies, and request properties.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ConnectionType,
|
|
10
|
+
EmotionState,
|
|
11
|
+
UserContext,
|
|
12
|
+
UserTier,
|
|
13
|
+
Viewport,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Cookie Helpers
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
function parseCookies(cookieHeader: string | null): Record<string, string> {
|
|
21
|
+
if (!cookieHeader) return {};
|
|
22
|
+
|
|
23
|
+
return cookieHeader.split(';').reduce(
|
|
24
|
+
(acc, cookie) => {
|
|
25
|
+
const [key, value] = cookie.trim().split('=');
|
|
26
|
+
if (key && value) {
|
|
27
|
+
acc[key] = decodeURIComponent(value);
|
|
28
|
+
}
|
|
29
|
+
return acc;
|
|
30
|
+
},
|
|
31
|
+
{} as Record<string, string>,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseJSON<T>(value: string | undefined, fallback: T): T {
|
|
36
|
+
if (!value) return fallback;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(value) as T;
|
|
39
|
+
} catch {
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Header Extraction
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract viewport from client hints or fallback headers
|
|
50
|
+
*/
|
|
51
|
+
function extractViewport(request: Request): Viewport {
|
|
52
|
+
const headers = request.headers;
|
|
53
|
+
|
|
54
|
+
// Client Hints (if available)
|
|
55
|
+
const viewportWidth = headers.get('sec-ch-viewport-width');
|
|
56
|
+
const viewportHeight = headers.get('sec-ch-viewport-height');
|
|
57
|
+
const dpr = headers.get('sec-ch-dpr');
|
|
58
|
+
|
|
59
|
+
if (viewportWidth && viewportHeight) {
|
|
60
|
+
return {
|
|
61
|
+
width: parseInt(viewportWidth, 10),
|
|
62
|
+
height: parseInt(viewportHeight, 10),
|
|
63
|
+
devicePixelRatio: dpr ? parseFloat(dpr) : undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Fallback to custom headers (set by client-side JS)
|
|
68
|
+
const xViewport = headers.get('x-viewport');
|
|
69
|
+
if (xViewport) {
|
|
70
|
+
const [width, height, devicePixelRatio] = xViewport.split(',').map(Number);
|
|
71
|
+
return { width: width || 1920, height: height || 1080, devicePixelRatio };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Default desktop viewport
|
|
75
|
+
return { width: 1920, height: 1080 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract connection type from Network Information API hints
|
|
80
|
+
*/
|
|
81
|
+
function extractConnection(request: Request): ConnectionType {
|
|
82
|
+
const headers = request.headers;
|
|
83
|
+
|
|
84
|
+
// Downlink and RTT for connection quality
|
|
85
|
+
const downlink = headers.get('downlink');
|
|
86
|
+
const rtt = headers.get('rtt');
|
|
87
|
+
const ect = headers.get('ect'); // Effective Connection Type
|
|
88
|
+
|
|
89
|
+
// ECT header (if available)
|
|
90
|
+
if (ect) {
|
|
91
|
+
switch (ect) {
|
|
92
|
+
case '4g':
|
|
93
|
+
return 'fast';
|
|
94
|
+
case '3g':
|
|
95
|
+
return '3g';
|
|
96
|
+
case '2g':
|
|
97
|
+
return '2g';
|
|
98
|
+
case 'slow-2g':
|
|
99
|
+
return 'slow-2g';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Infer from downlink (Mbps)
|
|
104
|
+
if (downlink) {
|
|
105
|
+
const mbps = parseFloat(downlink);
|
|
106
|
+
if (mbps >= 10) return 'fast';
|
|
107
|
+
if (mbps >= 2) return '4g';
|
|
108
|
+
if (mbps >= 0.5) return '3g';
|
|
109
|
+
if (mbps >= 0.1) return '2g';
|
|
110
|
+
return 'slow-2g';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Infer from RTT (ms)
|
|
114
|
+
if (rtt) {
|
|
115
|
+
const ms = parseInt(rtt, 10);
|
|
116
|
+
if (ms < 50) return 'fast';
|
|
117
|
+
if (ms < 100) return '4g';
|
|
118
|
+
if (ms < 300) return '3g';
|
|
119
|
+
if (ms < 700) return '2g';
|
|
120
|
+
return 'slow-2g';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Default to 4g
|
|
124
|
+
return '4g';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract reduced motion preference
|
|
129
|
+
*/
|
|
130
|
+
function extractReducedMotion(request: Request): boolean {
|
|
131
|
+
const prefersReducedMotion = request.headers.get(
|
|
132
|
+
'sec-ch-prefers-reduced-motion',
|
|
133
|
+
);
|
|
134
|
+
return prefersReducedMotion === 'reduce';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extract timezone and local hour
|
|
139
|
+
*/
|
|
140
|
+
function extractTimeContext(request: Request): {
|
|
141
|
+
timezone: string;
|
|
142
|
+
localHour: number;
|
|
143
|
+
} {
|
|
144
|
+
const headers = request.headers;
|
|
145
|
+
|
|
146
|
+
// Custom header from client
|
|
147
|
+
const xTimezone = headers.get('x-timezone');
|
|
148
|
+
const xLocalHour = headers.get('x-local-hour');
|
|
149
|
+
|
|
150
|
+
// Cloudflare provides timezone in cf object
|
|
151
|
+
const cfTimezone = (request as Request & { cf?: { timezone?: string } }).cf
|
|
152
|
+
?.timezone;
|
|
153
|
+
|
|
154
|
+
const timezone = xTimezone || cfTimezone || 'UTC';
|
|
155
|
+
const localHour = xLocalHour
|
|
156
|
+
? parseInt(xLocalHour, 10)
|
|
157
|
+
: new Date().getUTCHours();
|
|
158
|
+
|
|
159
|
+
return { timezone, localHour };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// Cookie/Session Extraction
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Admin capability verifier function type
|
|
168
|
+
* This should be provided by the auth system (e.g., UCAN token verification)
|
|
169
|
+
*/
|
|
170
|
+
export type AdminVerifier = (token: string) => Promise<boolean>;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Extract user identity and tier from cookies/headers
|
|
174
|
+
* NOTE: isAdmin is NOT determined here - it must be verified via UCAN token
|
|
175
|
+
*/
|
|
176
|
+
function extractIdentity(
|
|
177
|
+
cookies: Record<string, string>,
|
|
178
|
+
request: Request,
|
|
179
|
+
): { userId?: string; tier: UserTier; authToken?: string } {
|
|
180
|
+
// User ID from cookie or header
|
|
181
|
+
const userId =
|
|
182
|
+
cookies['user_id'] || request.headers.get('x-user-id') || undefined;
|
|
183
|
+
|
|
184
|
+
// Tier from cookie, header, or default
|
|
185
|
+
const tierCookie = cookies['user_tier'] as UserTier | undefined;
|
|
186
|
+
const tierHeader = request.headers.get('x-user-tier') as UserTier | null;
|
|
187
|
+
const tier = tierCookie || tierHeader || 'free';
|
|
188
|
+
|
|
189
|
+
// Auth token for admin verification (DO NOT trust cookies for admin status!)
|
|
190
|
+
const authToken =
|
|
191
|
+
cookies['auth_token'] ||
|
|
192
|
+
request.headers.get('authorization')?.replace('Bearer ', '') ||
|
|
193
|
+
undefined;
|
|
194
|
+
|
|
195
|
+
return { userId, tier, authToken };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Extract navigation history from cookies
|
|
200
|
+
*/
|
|
201
|
+
function extractNavigationHistory(cookies: Record<string, string>): {
|
|
202
|
+
recentPages: string[];
|
|
203
|
+
dwellTimes: Map<string, number>;
|
|
204
|
+
clickPatterns: string[];
|
|
205
|
+
} {
|
|
206
|
+
const recentPages = parseJSON<string[]>(cookies['recent_pages'], []);
|
|
207
|
+
const dwellTimesObj = parseJSON<Record<string, number>>(
|
|
208
|
+
cookies['dwell_times'],
|
|
209
|
+
{},
|
|
210
|
+
);
|
|
211
|
+
const clickPatterns = parseJSON<string[]>(cookies['click_patterns'], []);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
recentPages,
|
|
215
|
+
dwellTimes: new Map(Object.entries(dwellTimesObj)),
|
|
216
|
+
clickPatterns,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Extract emotion state from cookies/headers
|
|
222
|
+
*/
|
|
223
|
+
function extractEmotionState(
|
|
224
|
+
cookies: Record<string, string>,
|
|
225
|
+
request: Request,
|
|
226
|
+
): EmotionState | undefined {
|
|
227
|
+
// Check custom header first (set by edge-workers inference)
|
|
228
|
+
const xEmotion = request.headers.get('x-emotion-state');
|
|
229
|
+
if (xEmotion) {
|
|
230
|
+
return parseJSON<EmotionState | undefined>(xEmotion, undefined);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check cookie
|
|
234
|
+
const emotionCookie = cookies['emotion_state'];
|
|
235
|
+
if (emotionCookie) {
|
|
236
|
+
return parseJSON<EmotionState | undefined>(emotionCookie, undefined);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Extract user preferences from cookies
|
|
244
|
+
*/
|
|
245
|
+
function extractPreferences(
|
|
246
|
+
cookies: Record<string, string>,
|
|
247
|
+
): Record<string, unknown> {
|
|
248
|
+
return parseJSON<Record<string, unknown>>(cookies['user_preferences'], {});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extract session info from cookies
|
|
253
|
+
*/
|
|
254
|
+
function extractSessionInfo(cookies: Record<string, string>): {
|
|
255
|
+
sessionId?: string;
|
|
256
|
+
isNewSession: boolean;
|
|
257
|
+
sessionStartedAt?: Date;
|
|
258
|
+
} {
|
|
259
|
+
const sessionId = cookies['session_id'];
|
|
260
|
+
const sessionStarted = cookies['session_started'];
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
sessionId,
|
|
264
|
+
isNewSession: !sessionId,
|
|
265
|
+
sessionStartedAt: sessionStarted ? new Date(sessionStarted) : undefined,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// Main Extractor
|
|
271
|
+
// ============================================================================
|
|
272
|
+
|
|
273
|
+
export interface ContextExtractorOptions {
|
|
274
|
+
/** Custom emotion detector (e.g., call edge-workers) */
|
|
275
|
+
detectEmotion?: (request: Request) => Promise<EmotionState | undefined>;
|
|
276
|
+
|
|
277
|
+
/** Custom user tier resolver (e.g., from database) */
|
|
278
|
+
resolveUserTier?: (userId: string) => Promise<UserTier>;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Verify admin capability from auth token (REQUIRED for admin access)
|
|
282
|
+
* This should verify the UCAN token has the 'admin' capability
|
|
283
|
+
* If not provided, isAdmin will always be false
|
|
284
|
+
*/
|
|
285
|
+
verifyAdminCapability?: AdminVerifier;
|
|
286
|
+
|
|
287
|
+
/** Additional context enrichment */
|
|
288
|
+
enrich?: (context: UserContext, request: Request) => Promise<UserContext>;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Extract UserContext from an HTTP request
|
|
293
|
+
*/
|
|
294
|
+
export async function extractUserContext(
|
|
295
|
+
request: Request,
|
|
296
|
+
options: ContextExtractorOptions = {},
|
|
297
|
+
): Promise<UserContext> {
|
|
298
|
+
const cookies = parseCookies(request.headers.get('cookie'));
|
|
299
|
+
|
|
300
|
+
// Extract all signals
|
|
301
|
+
const viewport = extractViewport(request);
|
|
302
|
+
const connection = extractConnection(request);
|
|
303
|
+
const reducedMotion = extractReducedMotion(request);
|
|
304
|
+
const { timezone, localHour } = extractTimeContext(request);
|
|
305
|
+
const {
|
|
306
|
+
userId,
|
|
307
|
+
tier: initialTier,
|
|
308
|
+
authToken,
|
|
309
|
+
} = extractIdentity(cookies, request);
|
|
310
|
+
const { recentPages, dwellTimes, clickPatterns } =
|
|
311
|
+
extractNavigationHistory(cookies);
|
|
312
|
+
const preferences = extractPreferences(cookies);
|
|
313
|
+
const { sessionId, isNewSession, sessionStartedAt } =
|
|
314
|
+
extractSessionInfo(cookies);
|
|
315
|
+
|
|
316
|
+
// Resolve user tier if we have a resolver and userId
|
|
317
|
+
let tier = initialTier;
|
|
318
|
+
if (options.resolveUserTier && userId) {
|
|
319
|
+
try {
|
|
320
|
+
tier = await options.resolveUserTier(userId);
|
|
321
|
+
} catch {
|
|
322
|
+
// Keep initial tier on error
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Verify admin capability via UCAN token (NOT from cookies!)
|
|
327
|
+
// Admin access MUST be cryptographically verified
|
|
328
|
+
let isAdmin = false;
|
|
329
|
+
if (authToken && options.verifyAdminCapability) {
|
|
330
|
+
try {
|
|
331
|
+
isAdmin = await options.verifyAdminCapability(authToken);
|
|
332
|
+
} catch {
|
|
333
|
+
// Not admin on verification failure
|
|
334
|
+
isAdmin = false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Extract or detect emotion state
|
|
339
|
+
let emotionState = extractEmotionState(cookies, request);
|
|
340
|
+
if (!emotionState && options.detectEmotion) {
|
|
341
|
+
try {
|
|
342
|
+
emotionState = await options.detectEmotion(request);
|
|
343
|
+
} catch {
|
|
344
|
+
// No emotion state on error
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Build context
|
|
349
|
+
let context: UserContext = {
|
|
350
|
+
userId,
|
|
351
|
+
tier,
|
|
352
|
+
isAdmin,
|
|
353
|
+
recentPages,
|
|
354
|
+
dwellTimes,
|
|
355
|
+
clickPatterns,
|
|
356
|
+
emotionState,
|
|
357
|
+
preferences,
|
|
358
|
+
viewport,
|
|
359
|
+
connection,
|
|
360
|
+
reducedMotion,
|
|
361
|
+
localHour,
|
|
362
|
+
timezone,
|
|
363
|
+
sessionId,
|
|
364
|
+
isNewSession,
|
|
365
|
+
sessionStartedAt,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// Optional enrichment
|
|
369
|
+
if (options.enrich) {
|
|
370
|
+
context = await options.enrich(context, request);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return context;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Create middleware for user context extraction
|
|
378
|
+
*/
|
|
379
|
+
export function createContextMiddleware(options: ContextExtractorOptions = {}) {
|
|
380
|
+
return async (request: Request): Promise<UserContext> => {
|
|
381
|
+
return extractUserContext(request, options);
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ============================================================================
|
|
386
|
+
// Response Helpers
|
|
387
|
+
// ============================================================================
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Set context tracking cookies in response
|
|
391
|
+
*/
|
|
392
|
+
export function setContextCookies(
|
|
393
|
+
response: Response,
|
|
394
|
+
context: UserContext,
|
|
395
|
+
currentPath: string,
|
|
396
|
+
): Response {
|
|
397
|
+
const headers = new Headers(response.headers);
|
|
398
|
+
|
|
399
|
+
// Update recent pages
|
|
400
|
+
const recentPages = [...context.recentPages.slice(-9), currentPath];
|
|
401
|
+
headers.append(
|
|
402
|
+
'Set-Cookie',
|
|
403
|
+
`recent_pages=${encodeURIComponent(JSON.stringify(recentPages))}; Path=/; Max-Age=604800; SameSite=Lax`,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
// Set session cookie if new session
|
|
407
|
+
if (context.isNewSession) {
|
|
408
|
+
const sessionId = `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
409
|
+
headers.append(
|
|
410
|
+
'Set-Cookie',
|
|
411
|
+
`session_id=${sessionId}; Path=/; Max-Age=86400; SameSite=Lax`,
|
|
412
|
+
);
|
|
413
|
+
headers.append(
|
|
414
|
+
'Set-Cookie',
|
|
415
|
+
`session_started=${new Date().toISOString()}; Path=/; Max-Age=86400; SameSite=Lax`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return new Response(response.body, {
|
|
420
|
+
status: response.status,
|
|
421
|
+
statusText: response.statusText,
|
|
422
|
+
headers,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Add speculation hints to response headers
|
|
428
|
+
*/
|
|
429
|
+
export function addSpeculationHeaders(
|
|
430
|
+
response: Response,
|
|
431
|
+
prefetch: string[],
|
|
432
|
+
prerender: string[],
|
|
433
|
+
): Response {
|
|
434
|
+
const headers = new Headers(response.headers);
|
|
435
|
+
|
|
436
|
+
// Add Link headers for prefetch
|
|
437
|
+
if (prefetch.length > 0) {
|
|
438
|
+
const linkHeader = prefetch
|
|
439
|
+
.map((path) => `<${path}>; rel=prefetch`)
|
|
440
|
+
.join(', ');
|
|
441
|
+
headers.append('Link', linkHeader);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Add prerender hints (Speculation Rules API will be injected in HTML)
|
|
445
|
+
if (prerender.length > 0) {
|
|
446
|
+
headers.set('X-Prerender-Hints', prerender.join(','));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return new Response(response.body, {
|
|
450
|
+
status: response.status,
|
|
451
|
+
statusText: response.statusText,
|
|
452
|
+
headers,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ============================================================================
|
|
457
|
+
// ESI State Serialization
|
|
458
|
+
// ============================================================================
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* ESI State for global injection in <head>
|
|
462
|
+
* This is consumed by ESI components before React hydration
|
|
463
|
+
*/
|
|
464
|
+
export interface ESIState {
|
|
465
|
+
/** User subscription tier for feature gating */
|
|
466
|
+
userTier: UserTier;
|
|
467
|
+
/** Admin flag - bypasses ALL tier restrictions */
|
|
468
|
+
isAdmin?: boolean;
|
|
469
|
+
/** Current emotional state for personalization */
|
|
470
|
+
emotionState?: {
|
|
471
|
+
primary: string;
|
|
472
|
+
valence: number;
|
|
473
|
+
arousal: number;
|
|
474
|
+
confidence?: number;
|
|
475
|
+
};
|
|
476
|
+
/** User preferences for UI adaptation */
|
|
477
|
+
preferences: {
|
|
478
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
479
|
+
reducedMotion: boolean;
|
|
480
|
+
language?: string;
|
|
481
|
+
};
|
|
482
|
+
/** Session information */
|
|
483
|
+
sessionId?: string;
|
|
484
|
+
/** Local time context */
|
|
485
|
+
localHour: number;
|
|
486
|
+
timezone: string;
|
|
487
|
+
/** Feature flags based on tier */
|
|
488
|
+
features: {
|
|
489
|
+
aiInference: boolean;
|
|
490
|
+
emotionTracking: boolean;
|
|
491
|
+
collaboration: boolean;
|
|
492
|
+
advancedInsights: boolean;
|
|
493
|
+
customThemes: boolean;
|
|
494
|
+
voiceSynthesis: boolean;
|
|
495
|
+
imageAnalysis: boolean;
|
|
496
|
+
};
|
|
497
|
+
/** User ID (if authenticated) */
|
|
498
|
+
userId?: string;
|
|
499
|
+
/** Is this a new session? */
|
|
500
|
+
isNewSession: boolean;
|
|
501
|
+
/** Recent pages for personalization */
|
|
502
|
+
recentPages: string[];
|
|
503
|
+
/** Viewport information */
|
|
504
|
+
viewport: {
|
|
505
|
+
width: number;
|
|
506
|
+
height: number;
|
|
507
|
+
};
|
|
508
|
+
/** Connection quality */
|
|
509
|
+
connection: ConnectionType;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Serialize UserContext to ESIState for global injection
|
|
514
|
+
*/
|
|
515
|
+
export function serializeToESIState(context: UserContext): ESIState {
|
|
516
|
+
// Determine feature flags based on tier
|
|
517
|
+
const tierFeatures: Record<UserTier, ESIState['features']> = {
|
|
518
|
+
free: {
|
|
519
|
+
aiInference: true,
|
|
520
|
+
emotionTracking: true,
|
|
521
|
+
collaboration: false,
|
|
522
|
+
advancedInsights: false,
|
|
523
|
+
customThemes: false,
|
|
524
|
+
voiceSynthesis: false,
|
|
525
|
+
imageAnalysis: false,
|
|
526
|
+
},
|
|
527
|
+
starter: {
|
|
528
|
+
aiInference: true,
|
|
529
|
+
emotionTracking: true,
|
|
530
|
+
collaboration: false,
|
|
531
|
+
advancedInsights: true,
|
|
532
|
+
customThemes: true,
|
|
533
|
+
voiceSynthesis: false,
|
|
534
|
+
imageAnalysis: false,
|
|
535
|
+
},
|
|
536
|
+
pro: {
|
|
537
|
+
aiInference: true,
|
|
538
|
+
emotionTracking: true,
|
|
539
|
+
collaboration: true,
|
|
540
|
+
advancedInsights: true,
|
|
541
|
+
customThemes: true,
|
|
542
|
+
voiceSynthesis: true,
|
|
543
|
+
imageAnalysis: true,
|
|
544
|
+
},
|
|
545
|
+
enterprise: {
|
|
546
|
+
aiInference: true,
|
|
547
|
+
emotionTracking: true,
|
|
548
|
+
collaboration: true,
|
|
549
|
+
advancedInsights: true,
|
|
550
|
+
customThemes: true,
|
|
551
|
+
voiceSynthesis: true,
|
|
552
|
+
imageAnalysis: true,
|
|
553
|
+
},
|
|
554
|
+
admin: {
|
|
555
|
+
aiInference: true,
|
|
556
|
+
emotionTracking: true,
|
|
557
|
+
collaboration: true,
|
|
558
|
+
advancedInsights: true,
|
|
559
|
+
customThemes: true,
|
|
560
|
+
voiceSynthesis: true,
|
|
561
|
+
imageAnalysis: true,
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// Admins get ALL features regardless of tier
|
|
566
|
+
const features = context.isAdmin
|
|
567
|
+
? tierFeatures.admin
|
|
568
|
+
: tierFeatures[context.tier] || tierFeatures.free;
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
userTier: context.tier,
|
|
572
|
+
isAdmin: context.isAdmin,
|
|
573
|
+
emotionState: context.emotionState
|
|
574
|
+
? {
|
|
575
|
+
primary: context.emotionState.primary,
|
|
576
|
+
valence: context.emotionState.valence,
|
|
577
|
+
arousal: context.emotionState.arousal,
|
|
578
|
+
confidence: context.emotionState.confidence,
|
|
579
|
+
}
|
|
580
|
+
: undefined,
|
|
581
|
+
preferences: {
|
|
582
|
+
theme: context.preferences.theme as 'light' | 'dark' | 'auto' | undefined,
|
|
583
|
+
reducedMotion: context.reducedMotion,
|
|
584
|
+
language: context.preferences.language as string | undefined,
|
|
585
|
+
},
|
|
586
|
+
sessionId: context.sessionId,
|
|
587
|
+
localHour: context.localHour,
|
|
588
|
+
timezone: context.timezone,
|
|
589
|
+
features,
|
|
590
|
+
userId: context.userId,
|
|
591
|
+
isNewSession: context.isNewSession,
|
|
592
|
+
recentPages: context.recentPages.slice(-10), // Last 10 pages
|
|
593
|
+
viewport: {
|
|
594
|
+
width: context.viewport.width,
|
|
595
|
+
height: context.viewport.height,
|
|
596
|
+
},
|
|
597
|
+
connection: context.connection,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Generate inline script for ESI state injection in <head>
|
|
603
|
+
* This must execute before any ESI components render
|
|
604
|
+
*/
|
|
605
|
+
export function generateESIStateScript(esiState: ESIState): string {
|
|
606
|
+
const stateJson = JSON.stringify(esiState);
|
|
607
|
+
return `<script>window.__AEON_ESI_STATE__=${stateJson};</script>`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Generate ESI state script from UserContext
|
|
612
|
+
*/
|
|
613
|
+
export function generateESIStateScriptFromContext(
|
|
614
|
+
context: UserContext,
|
|
615
|
+
): string {
|
|
616
|
+
const esiState = serializeToESIState(context);
|
|
617
|
+
return generateESIStateScript(esiState);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ============================================================================
|
|
621
|
+
// Admin Verification Helpers
|
|
622
|
+
// ============================================================================
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Create an admin verifier from a UCAN auth instance
|
|
626
|
+
*
|
|
627
|
+
* @example
|
|
628
|
+
* ```ts
|
|
629
|
+
* import { auth } from './auth';
|
|
630
|
+
* import { createAdminVerifier, extractUserContext } from '@affectively/aeon-pages-runtime';
|
|
631
|
+
*
|
|
632
|
+
* const verifyAdmin = createAdminVerifier(auth);
|
|
633
|
+
*
|
|
634
|
+
* const context = await extractUserContext(request, {
|
|
635
|
+
* verifyAdminCapability: verifyAdmin,
|
|
636
|
+
* });
|
|
637
|
+
* ```
|
|
638
|
+
*/
|
|
639
|
+
export function createAdminVerifier<T>(
|
|
640
|
+
auth: {
|
|
641
|
+
verifyCapability: (opts: {
|
|
642
|
+
capability: T;
|
|
643
|
+
resource: string;
|
|
644
|
+
token: string;
|
|
645
|
+
}) => Promise<boolean>;
|
|
646
|
+
},
|
|
647
|
+
adminCapability: T = 'admin' as T,
|
|
648
|
+
adminResource: string = '*',
|
|
649
|
+
): AdminVerifier {
|
|
650
|
+
return async (token: string): Promise<boolean> => {
|
|
651
|
+
try {
|
|
652
|
+
return await auth.verifyCapability({
|
|
653
|
+
capability: adminCapability,
|
|
654
|
+
resource: adminResource,
|
|
655
|
+
token,
|
|
656
|
+
});
|
|
657
|
+
} catch {
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
}
|