@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.
Files changed (124) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +625 -0
  3. package/examples/basic/aeon.config.ts +39 -0
  4. package/examples/basic/components/Cursor.tsx +86 -0
  5. package/examples/basic/components/OfflineIndicator.tsx +103 -0
  6. package/examples/basic/components/PresenceBar.tsx +77 -0
  7. package/examples/basic/package.json +20 -0
  8. package/examples/basic/pages/index.tsx +80 -0
  9. package/package.json +101 -0
  10. package/packages/analytics/README.md +309 -0
  11. package/packages/analytics/build.ts +35 -0
  12. package/packages/analytics/package.json +50 -0
  13. package/packages/analytics/src/click-tracker.ts +368 -0
  14. package/packages/analytics/src/context-bridge.ts +319 -0
  15. package/packages/analytics/src/data-layer.ts +302 -0
  16. package/packages/analytics/src/gtm-loader.ts +239 -0
  17. package/packages/analytics/src/index.ts +230 -0
  18. package/packages/analytics/src/merkle-tree.ts +489 -0
  19. package/packages/analytics/src/provider.tsx +300 -0
  20. package/packages/analytics/src/types.ts +320 -0
  21. package/packages/analytics/src/use-analytics.ts +296 -0
  22. package/packages/analytics/tsconfig.json +19 -0
  23. package/packages/benchmarks/src/benchmark.test.ts +691 -0
  24. package/packages/cli/dist/index.js +61899 -0
  25. package/packages/cli/package.json +43 -0
  26. package/packages/cli/src/commands/build.test.ts +682 -0
  27. package/packages/cli/src/commands/build.ts +890 -0
  28. package/packages/cli/src/commands/dev.ts +473 -0
  29. package/packages/cli/src/commands/init.ts +409 -0
  30. package/packages/cli/src/commands/start.ts +297 -0
  31. package/packages/cli/src/index.ts +105 -0
  32. package/packages/directives/src/use-aeon.ts +272 -0
  33. package/packages/mcp-server/package.json +51 -0
  34. package/packages/mcp-server/src/index.ts +178 -0
  35. package/packages/mcp-server/src/resources.ts +346 -0
  36. package/packages/mcp-server/src/tools/index.ts +36 -0
  37. package/packages/mcp-server/src/tools/navigation.ts +545 -0
  38. package/packages/mcp-server/tsconfig.json +21 -0
  39. package/packages/react/package.json +40 -0
  40. package/packages/react/src/Link.tsx +388 -0
  41. package/packages/react/src/components/InstallPrompt.tsx +286 -0
  42. package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
  43. package/packages/react/src/components/PushNotifications.tsx +453 -0
  44. package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
  45. package/packages/react/src/hooks/useConflicts.ts +277 -0
  46. package/packages/react/src/hooks/useNetworkState.ts +209 -0
  47. package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
  48. package/packages/react/src/hooks/useServiceWorker.ts +278 -0
  49. package/packages/react/src/hooks.ts +195 -0
  50. package/packages/react/src/index.ts +151 -0
  51. package/packages/react/src/provider.tsx +467 -0
  52. package/packages/react/tsconfig.json +19 -0
  53. package/packages/runtime/README.md +399 -0
  54. package/packages/runtime/build.ts +48 -0
  55. package/packages/runtime/package.json +71 -0
  56. package/packages/runtime/schema.sql +40 -0
  57. package/packages/runtime/src/api-routes.ts +465 -0
  58. package/packages/runtime/src/benchmark.ts +171 -0
  59. package/packages/runtime/src/cache.ts +479 -0
  60. package/packages/runtime/src/durable-object.ts +1341 -0
  61. package/packages/runtime/src/index.ts +360 -0
  62. package/packages/runtime/src/navigation.test.ts +421 -0
  63. package/packages/runtime/src/navigation.ts +422 -0
  64. package/packages/runtime/src/nextjs-adapter.ts +272 -0
  65. package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
  66. package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
  67. package/packages/runtime/src/offline/encryption.test.ts +412 -0
  68. package/packages/runtime/src/offline/encryption.ts +397 -0
  69. package/packages/runtime/src/offline/types.ts +465 -0
  70. package/packages/runtime/src/predictor.ts +371 -0
  71. package/packages/runtime/src/registry.ts +351 -0
  72. package/packages/runtime/src/router/context-extractor.ts +661 -0
  73. package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
  74. package/packages/runtime/src/router/esi-control.ts +541 -0
  75. package/packages/runtime/src/router/esi-cyrano.ts +779 -0
  76. package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
  77. package/packages/runtime/src/router/esi-react.tsx +1065 -0
  78. package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
  79. package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
  80. package/packages/runtime/src/router/esi-translate.ts +503 -0
  81. package/packages/runtime/src/router/esi.ts +666 -0
  82. package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
  83. package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
  84. package/packages/runtime/src/router/index.ts +298 -0
  85. package/packages/runtime/src/router/merkle-capability.ts +473 -0
  86. package/packages/runtime/src/router/speculation.ts +451 -0
  87. package/packages/runtime/src/router/types.ts +630 -0
  88. package/packages/runtime/src/router.test.ts +470 -0
  89. package/packages/runtime/src/router.ts +302 -0
  90. package/packages/runtime/src/server.ts +481 -0
  91. package/packages/runtime/src/service-worker-push.ts +319 -0
  92. package/packages/runtime/src/service-worker.ts +553 -0
  93. package/packages/runtime/src/skeleton-hydrate.ts +237 -0
  94. package/packages/runtime/src/speculation.test.ts +389 -0
  95. package/packages/runtime/src/speculation.ts +486 -0
  96. package/packages/runtime/src/storage.test.ts +1297 -0
  97. package/packages/runtime/src/storage.ts +1048 -0
  98. package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
  99. package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
  100. package/packages/runtime/src/sync/coordinator.test.ts +608 -0
  101. package/packages/runtime/src/sync/coordinator.ts +596 -0
  102. package/packages/runtime/src/tree-compiler.ts +295 -0
  103. package/packages/runtime/src/types.ts +728 -0
  104. package/packages/runtime/src/worker.ts +327 -0
  105. package/packages/runtime/tsconfig.json +20 -0
  106. package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
  107. package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
  108. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
  109. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
  110. package/packages/runtime/wasm/package.json +21 -0
  111. package/packages/runtime/wrangler.toml +41 -0
  112. package/packages/runtime-wasm/Cargo.lock +436 -0
  113. package/packages/runtime-wasm/Cargo.toml +29 -0
  114. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
  115. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
  116. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  117. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
  118. package/packages/runtime-wasm/pkg/package.json +21 -0
  119. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  120. package/packages/runtime-wasm/src/lib.rs +191 -0
  121. package/packages/runtime-wasm/src/render.rs +629 -0
  122. package/packages/runtime-wasm/src/router.rs +298 -0
  123. package/packages/runtime-wasm/src/skeleton.rs +430 -0
  124. package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
@@ -0,0 +1,1341 @@
1
+ /**
2
+ * Aeon Pages Durable Object
3
+ *
4
+ * This is the Cloudflare Durable Object that provides:
5
+ * - Strong consistency for page sessions
6
+ * - Real-time WebSocket connections for collaboration
7
+ * - Presence tracking across connected clients
8
+ * - CRDT-based conflict resolution
9
+ *
10
+ * Deploy this alongside your Cloudflare Worker.
11
+ */
12
+
13
+ import type {
14
+ PageSession,
15
+ SerializedComponent,
16
+ PresenceUser,
17
+ WebhookConfig,
18
+ WebhookPayload,
19
+ } from './types';
20
+ import { compileTreeToTSX } from './tree-compiler';
21
+
22
+ interface Env {
23
+ // D1 database for async propagation
24
+ DB?: D1Database;
25
+ // GitHub integration for tree PRs
26
+ GITHUB_TOKEN?: string;
27
+ GITHUB_REPO?: string; // "owner/repo"
28
+ GITHUB_TREE_PATH?: string; // e.g., "apps/web/trees" or "packages/app/src/trees"
29
+ GITHUB_BASE_BRANCH?: string; // Target branch for PRs (default: repo default)
30
+ GITHUB_DEV_BRANCH?: string; // Branch to create from (default: base branch)
31
+ GITHUB_AUTO_MERGE?: string; // "true" to auto-merge PRs
32
+ // Webhook secret for GitHub verification
33
+ GITHUB_WEBHOOK_SECRET?: string;
34
+ // Callback URL when session changes (for sync)
35
+ SYNC_WEBHOOK_URL?: string;
36
+ }
37
+
38
+ interface D1Database {
39
+ prepare(query: string): D1PreparedStatement;
40
+ }
41
+
42
+ interface D1PreparedStatement {
43
+ bind(...values: unknown[]): D1PreparedStatement;
44
+ run(): Promise<void>;
45
+ }
46
+
47
+ interface WebSocketMessage {
48
+ type:
49
+ | 'cursor'
50
+ | 'edit'
51
+ | 'presence'
52
+ | 'sync'
53
+ | 'ping'
54
+ | 'publish'
55
+ | 'merge'
56
+ | 'queue-sync'
57
+ | 'conflict'
58
+ | 'conflict-resolved';
59
+ payload: unknown;
60
+ }
61
+
62
+ interface PublishPayload {
63
+ prNumber?: number; // For merge operations
64
+ }
65
+
66
+ interface CursorPayload {
67
+ x: number;
68
+ y: number;
69
+ }
70
+
71
+ interface EditPayload {
72
+ path: string;
73
+ value: unknown;
74
+ timestamp: string;
75
+ }
76
+
77
+ interface PresencePayload {
78
+ status: 'online' | 'away' | 'offline';
79
+ editing?: string;
80
+ }
81
+
82
+ /**
83
+ * Aeon Page Session Durable Object
84
+ *
85
+ * One instance per page session, handles:
86
+ * - Session state (component tree, data)
87
+ * - Real-time presence
88
+ * - WebSocket connections
89
+ * - Collaborative editing
90
+ */
91
+ export class AeonPageSession {
92
+ private state: DurableObjectState;
93
+ private env: Env;
94
+ private sessions: Map<WebSocket, PresenceUser> = new Map();
95
+ private session: PageSession | null = null;
96
+ private webhooks: WebhookConfig[] = [];
97
+
98
+ constructor(state: DurableObjectState, env: Env) {
99
+ this.state = state;
100
+ this.env = env;
101
+
102
+ // Load webhooks from storage on init
103
+ this.state.blockConcurrencyWhile(async () => {
104
+ this.webhooks =
105
+ (await this.state.storage.get<WebhookConfig[]>('webhooks')) || [];
106
+ });
107
+ }
108
+
109
+ async fetch(request: Request): Promise<Response> {
110
+ const url = new URL(request.url);
111
+
112
+ // Handle WebSocket upgrade
113
+ if (request.headers.get('Upgrade') === 'websocket') {
114
+ return this.handleWebSocket(request);
115
+ }
116
+
117
+ // Handle REST API
118
+ switch (url.pathname) {
119
+ case '/':
120
+ case '/session':
121
+ return this.handleSessionRequest(request);
122
+ case '/init':
123
+ return this.handleInitRequest(request);
124
+ case '/tree':
125
+ return this.handleTreeRequest(request);
126
+ case '/presence':
127
+ return this.handlePresenceRequest(request);
128
+ case '/webhook':
129
+ return this.handleWebhookEndpoint(request);
130
+ case '/webhooks':
131
+ return this.handleWebhooksConfig(request);
132
+ case '/version':
133
+ return this.handleVersionRequest(request);
134
+ case '/sync-queue':
135
+ return this.handleSyncQueueRequest(request);
136
+ case '/queue-status':
137
+ return this.handleQueueStatusRequest(request);
138
+ case '/resolve-conflict':
139
+ return this.handleResolveConflictRequest(request);
140
+ default:
141
+ return new Response('Not found', { status: 404 });
142
+ }
143
+ }
144
+
145
+ private async handleWebSocket(request: Request): Promise<Response> {
146
+ const pair = new WebSocketPair();
147
+ const [client, server] = Object.values(pair);
148
+
149
+ // Get user info from query params or headers
150
+ const url = new URL(request.url);
151
+ const userId = url.searchParams.get('userId') || crypto.randomUUID();
152
+ const role = (url.searchParams.get('role') ||
153
+ 'user') as PresenceUser['role'];
154
+
155
+ // Accept the WebSocket
156
+ (server as WebSocket & { accept: () => void }).accept();
157
+
158
+ // Create presence entry
159
+ const presence: PresenceUser = {
160
+ userId,
161
+ role,
162
+ status: 'online',
163
+ lastActivity: new Date().toISOString(),
164
+ };
165
+
166
+ this.sessions.set(server, presence);
167
+
168
+ // Send initial state
169
+ const session = await this.getSession();
170
+ if (session) {
171
+ server.send(
172
+ JSON.stringify({
173
+ type: 'init',
174
+ payload: {
175
+ session,
176
+ presence: Array.from(this.sessions.values()),
177
+ },
178
+ }),
179
+ );
180
+ }
181
+
182
+ // Broadcast join to others
183
+ this.broadcast(
184
+ {
185
+ type: 'presence',
186
+ payload: {
187
+ action: 'join',
188
+ user: presence,
189
+ },
190
+ },
191
+ server,
192
+ );
193
+
194
+ // Handle messages
195
+ server.addEventListener('message', async (event: MessageEvent) => {
196
+ try {
197
+ const message = JSON.parse(event.data as string) as WebSocketMessage;
198
+ await this.handleMessage(server, message);
199
+ } catch (err) {
200
+ console.error('Failed to handle message:', err);
201
+ }
202
+ });
203
+
204
+ // Handle disconnect
205
+ server.addEventListener('close', () => {
206
+ const user = this.sessions.get(server);
207
+ this.sessions.delete(server);
208
+
209
+ if (user) {
210
+ this.broadcast({
211
+ type: 'presence',
212
+ payload: {
213
+ action: 'leave',
214
+ userId: user.userId,
215
+ },
216
+ });
217
+ }
218
+ });
219
+
220
+
221
+ return new Response(null, { status: 101, webSocket: client });
222
+ }
223
+
224
+ private async handleMessage(
225
+ ws: WebSocket,
226
+ message: WebSocketMessage,
227
+ ): Promise<void> {
228
+ const user = this.sessions.get(ws);
229
+ if (!user) return;
230
+
231
+ // Update last activity
232
+ user.lastActivity = new Date().toISOString();
233
+
234
+ switch (message.type) {
235
+ case 'cursor': {
236
+ const payload = message.payload as CursorPayload;
237
+ user.cursor = { x: payload.x, y: payload.y };
238
+ this.broadcast(
239
+ {
240
+ type: 'cursor',
241
+ payload: {
242
+ userId: user.userId,
243
+ cursor: user.cursor,
244
+ },
245
+ },
246
+ ws,
247
+ );
248
+ break;
249
+ }
250
+
251
+ case 'edit': {
252
+ const payload = message.payload as EditPayload;
253
+ await this.applyEdit(payload, user.userId);
254
+ this.broadcast(
255
+ {
256
+ type: 'edit',
257
+ payload: {
258
+ ...payload,
259
+ userId: user.userId,
260
+ },
261
+ },
262
+ ws,
263
+ );
264
+ break;
265
+ }
266
+
267
+ case 'presence': {
268
+ const payload = message.payload as PresencePayload;
269
+ user.status = payload.status;
270
+ user.editing = payload.editing;
271
+ this.broadcast(
272
+ {
273
+ type: 'presence',
274
+ payload: {
275
+ action: 'update',
276
+ user,
277
+ },
278
+ },
279
+ ws,
280
+ );
281
+ break;
282
+ }
283
+
284
+ case 'ping': {
285
+ ws.send(
286
+ JSON.stringify({ type: 'pong', payload: { timestamp: Date.now() } }),
287
+ );
288
+ break;
289
+ }
290
+
291
+ case 'publish': {
292
+ // Trigger a PR for current tree state
293
+ const session = await this.getSession();
294
+ if (session) {
295
+ const prNumber = await this.createTreePR(session);
296
+ const autoMerged = this.env.GITHUB_AUTO_MERGE === 'true';
297
+ ws.send(
298
+ JSON.stringify({
299
+ type: 'publish',
300
+ payload: {
301
+ status: 'created',
302
+ route: session.route,
303
+ prNumber,
304
+ autoMerged,
305
+ },
306
+ }),
307
+ );
308
+ this.broadcast(
309
+ {
310
+ type: 'publish',
311
+ payload: {
312
+ status: 'created',
313
+ userId: user.userId,
314
+ route: session.route,
315
+ prNumber,
316
+ autoMerged,
317
+ },
318
+ },
319
+ ws,
320
+ );
321
+
322
+ // Fire webhook for publish event
323
+ await this.fireWebhook(
324
+ 'session.published',
325
+ session,
326
+ prNumber as number | undefined,
327
+ user.userId,
328
+ );
329
+ }
330
+ break;
331
+ }
332
+
333
+ case 'merge': {
334
+ // Merge a specific PR
335
+ const payload = message.payload as PublishPayload;
336
+ if (payload.prNumber) {
337
+ const merged = await this.mergePR(payload.prNumber);
338
+ ws.send(
339
+ JSON.stringify({
340
+ type: 'merge',
341
+ payload: {
342
+ status: merged ? 'merged' : 'failed',
343
+ prNumber: payload.prNumber,
344
+ },
345
+ }),
346
+ );
347
+ if (merged) {
348
+ this.broadcast(
349
+ {
350
+ type: 'merge',
351
+ payload: {
352
+ status: 'merged',
353
+ userId: user.userId,
354
+ prNumber: payload.prNumber,
355
+ },
356
+ },
357
+ ws,
358
+ );
359
+
360
+ // Fire webhook for merge event
361
+ const session = await this.getSession();
362
+ if (session) {
363
+ await this.fireWebhook(
364
+ 'session.merged',
365
+ session,
366
+ payload.prNumber,
367
+ user.userId,
368
+ );
369
+ }
370
+ }
371
+ }
372
+ break;
373
+ }
374
+ }
375
+ }
376
+
377
+ private broadcast(message: object, exclude?: WebSocket): void {
378
+ const data = JSON.stringify(message);
379
+ for (const [ws] of this.sessions) {
380
+ if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
381
+ ws.send(data);
382
+ }
383
+ }
384
+ }
385
+
386
+ private async applyEdit(edit: EditPayload, userId?: string): Promise<void> {
387
+ const session = await this.getSession();
388
+ if (!session) return;
389
+
390
+ // Apply edit to tree using path
391
+ const parts = edit.path.split('.');
392
+ let current: unknown = session.tree;
393
+
394
+ for (let i = 0; i < parts.length - 1; i++) {
395
+ const part = parts[i];
396
+ if (typeof current === 'object' && current !== null) {
397
+ current = (current as Record<string, unknown>)[part];
398
+ }
399
+ }
400
+
401
+ if (typeof current === 'object' && current !== null) {
402
+ const lastPart = parts[parts.length - 1];
403
+ (current as Record<string, unknown>)[lastPart] = edit.value;
404
+ }
405
+
406
+ // Save updated session with version increment and webhook
407
+ await this.saveSession(session, userId);
408
+
409
+ // Async propagate to D1
410
+ if (this.env.DB) {
411
+ this.state.waitUntil(this.propagateToD1(session));
412
+ }
413
+ }
414
+
415
+ private async propagateToD1(session: PageSession): Promise<void> {
416
+ if (!this.env.DB) return;
417
+
418
+ try {
419
+ await this.env.DB.prepare(
420
+ `
421
+ INSERT OR REPLACE INTO sessions (session_id, route, tree, data, schema_version, updated_at)
422
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
423
+ `,
424
+ )
425
+ .bind(
426
+ this.state.id.toString(),
427
+ session.route,
428
+ JSON.stringify(session.tree),
429
+ JSON.stringify(session.data),
430
+ session.schema.version,
431
+ )
432
+ .run();
433
+ } catch (err) {
434
+ console.error('Failed to propagate to D1:', err);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Create a GitHub PR when tree changes
440
+ */
441
+ private async createTreePR(
442
+ session: PageSession,
443
+ ): Promise<number | undefined> {
444
+ if (!this.env.GITHUB_TOKEN || !this.env.GITHUB_REPO) return undefined;
445
+
446
+ const [owner, repo] = this.env.GITHUB_REPO.split('/');
447
+ const branch = `tree/${session.route.replace(/\//g, '-') || 'index'}-${Date.now()}`;
448
+ const basePath = this.env.GITHUB_TREE_PATH || 'pages';
449
+ const routePath = session.route === '/' ? '/index' : session.route;
450
+ const path = `${basePath}${routePath}/page.tsx`;
451
+
452
+ // Compile tree to TSX
453
+ const tsx = compileTreeToTSX(session.tree as any, {
454
+ route: session.route,
455
+ useAeon: true,
456
+ });
457
+ const content = btoa(tsx);
458
+
459
+ try {
460
+ const headers = {
461
+ Authorization: `token ${this.env.GITHUB_TOKEN}`,
462
+ 'User-Agent': 'aeon-flux',
463
+ };
464
+
465
+ // Get repo info for default branch
466
+ const repoRes = await fetch(
467
+ `https://api.github.com/repos/${owner}/${repo}`,
468
+ { headers },
469
+ );
470
+ const repoData = (await repoRes.json()) as { default_branch: string };
471
+
472
+ // Determine branches
473
+ const baseBranch = this.env.GITHUB_BASE_BRANCH || repoData.default_branch;
474
+ const devBranch = this.env.GITHUB_DEV_BRANCH || baseBranch;
475
+
476
+ // Get SHA from dev branch (branch off from here)
477
+ const refRes = await fetch(
478
+ `https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${devBranch}`,
479
+ { headers },
480
+ );
481
+ const refData = (await refRes.json()) as { object: { sha: string } };
482
+
483
+ // Create feature branch
484
+ await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs`, {
485
+ method: 'POST',
486
+ headers: { ...headers, 'Content-Type': 'application/json' },
487
+ body: JSON.stringify({
488
+ ref: `refs/heads/${branch}`,
489
+ sha: refData.object.sha,
490
+ }),
491
+ });
492
+
493
+ // Create/update file
494
+ await fetch(
495
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
496
+ {
497
+ method: 'PUT',
498
+ headers: { ...headers, 'Content-Type': 'application/json' },
499
+ body: JSON.stringify({
500
+ message: `Update tree: ${session.route}`,
501
+ content,
502
+ branch,
503
+ }),
504
+ },
505
+ );
506
+
507
+ // Create PR targeting base branch
508
+ const prRes = await fetch(
509
+ `https://api.github.com/repos/${owner}/${repo}/pulls`,
510
+ {
511
+ method: 'POST',
512
+ headers: { ...headers, 'Content-Type': 'application/json' },
513
+ body: JSON.stringify({
514
+ title: `🌳 Tree update: ${session.route}`,
515
+ head: branch,
516
+ base: baseBranch,
517
+ body: `Automated PR from aeon-flux collaborative editing.\n\n**Route:** \`${session.route}\`\n**Session:** \`${this.state.id.toString()}\`\n**From:** \`${devBranch}\` → \`${baseBranch}\``,
518
+ }),
519
+ },
520
+ );
521
+ const prData = (await prRes.json()) as { number: number };
522
+
523
+ // Auto-merge if enabled
524
+ if (this.env.GITHUB_AUTO_MERGE === 'true' && prData.number) {
525
+ await this.mergePR(prData.number);
526
+ }
527
+
528
+ return prData.number;
529
+ } catch (err) {
530
+ console.error('Failed to create PR:', err);
531
+ return undefined;
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Merge a GitHub PR
537
+ */
538
+ private async mergePR(prNumber: number): Promise<boolean> {
539
+ if (!this.env.GITHUB_TOKEN || !this.env.GITHUB_REPO) return false;
540
+
541
+ const [owner, repo] = this.env.GITHUB_REPO.split('/');
542
+ const headers = {
543
+ Authorization: `token ${this.env.GITHUB_TOKEN}`,
544
+ 'User-Agent': 'aeon-flux',
545
+ };
546
+
547
+ try {
548
+ const res = await fetch(
549
+ `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/merge`,
550
+ {
551
+ method: 'PUT',
552
+ headers: { ...headers, 'Content-Type': 'application/json' },
553
+ body: JSON.stringify({
554
+ commit_title: `🌳 Merge tree update #${prNumber}`,
555
+ merge_method: 'squash',
556
+ }),
557
+ },
558
+ );
559
+ return res.ok;
560
+ } catch (err) {
561
+ console.error('Failed to merge PR:', err);
562
+ return false;
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Handle GitHub webhook callbacks (push events)
568
+ * This is called when GitHub pushes changes to the repo
569
+ */
570
+ private async handleWebhookEndpoint(request: Request): Promise<Response> {
571
+ if (request.method !== 'POST') {
572
+ return new Response('Method not allowed', { status: 405 });
573
+ }
574
+
575
+ // Verify GitHub signature if secret is configured
576
+ if (this.env.GITHUB_WEBHOOK_SECRET) {
577
+ const signature = request.headers.get('X-Hub-Signature-256');
578
+ if (!signature) {
579
+ return new Response('Missing signature', { status: 401 });
580
+ }
581
+
582
+ const body = await request.text();
583
+ const isValid = await this.verifyGitHubSignature(body, signature);
584
+ if (!isValid) {
585
+ return new Response('Invalid signature', { status: 401 });
586
+ }
587
+
588
+ // Parse the verified body
589
+ const payload = JSON.parse(body) as {
590
+ ref?: string;
591
+ commits?: Array<{ modified?: string[]; added?: string[] }>;
592
+ };
593
+ return this.processGitHubWebhook(
594
+ payload,
595
+ request.headers.get('X-GitHub-Event') || 'push',
596
+ );
597
+ }
598
+
599
+ // No secret configured, process directly
600
+ const payload = (await request.json()) as {
601
+ ref?: string;
602
+ commits?: Array<{ modified?: string[]; added?: string[] }>;
603
+ };
604
+ return this.processGitHubWebhook(
605
+ payload,
606
+ request.headers.get('X-GitHub-Event') || 'push',
607
+ );
608
+ }
609
+
610
+ /**
611
+ * Verify GitHub webhook signature
612
+ */
613
+ private async verifyGitHubSignature(
614
+ body: string,
615
+ signature: string,
616
+ ): Promise<boolean> {
617
+ if (!this.env.GITHUB_WEBHOOK_SECRET) return false;
618
+
619
+ const encoder = new TextEncoder();
620
+ const key = await crypto.subtle.importKey(
621
+ 'raw',
622
+ encoder.encode(this.env.GITHUB_WEBHOOK_SECRET),
623
+ { name: 'HMAC', hash: 'SHA-256' },
624
+ false,
625
+ ['sign'],
626
+ );
627
+
628
+ const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
629
+ const computed =
630
+ 'sha256=' +
631
+ Array.from(new Uint8Array(sig))
632
+ .map((b) => b.toString(16).padStart(2, '0'))
633
+ .join('');
634
+
635
+ return signature === computed;
636
+ }
637
+
638
+ /**
639
+ * Process GitHub webhook events
640
+ */
641
+ private async processGitHubWebhook(
642
+ payload: {
643
+ ref?: string;
644
+ commits?: Array<{ modified?: string[]; added?: string[] }>;
645
+ },
646
+ event: string,
647
+ ): Promise<Response> {
648
+ // Only process push events
649
+ if (event !== 'push') {
650
+ return Response.json({ status: 'ignored', event });
651
+ }
652
+
653
+ // Check if this push affects our tree path
654
+ const treePath = this.env.GITHUB_TREE_PATH || 'pages';
655
+ const affectedFiles = [
656
+ ...(payload.commits?.flatMap((c) => c.modified || []) || []),
657
+ ...(payload.commits?.flatMap((c) => c.added || []) || []),
658
+ ];
659
+
660
+ const relevantFiles = affectedFiles.filter((f) => f.startsWith(treePath));
661
+ if (relevantFiles.length === 0) {
662
+ return Response.json({ status: 'ignored', reason: 'no relevant files' });
663
+ }
664
+
665
+ // Fire webhook to notify sync system
666
+ const session = await this.getSession();
667
+ if (session) {
668
+ await this.fireWebhook('github.push', session, undefined, 'github');
669
+ }
670
+
671
+ // Broadcast to connected clients
672
+ this.broadcast({
673
+ type: 'sync',
674
+ payload: {
675
+ action: 'github-push',
676
+ files: relevantFiles,
677
+ timestamp: new Date().toISOString(),
678
+ },
679
+ });
680
+
681
+ return Response.json({ status: 'processed', files: relevantFiles });
682
+ }
683
+
684
+ /**
685
+ * Handle webhook configuration (register/list)
686
+ */
687
+ private async handleWebhooksConfig(request: Request): Promise<Response> {
688
+ switch (request.method) {
689
+ case 'GET': {
690
+ // List registered webhooks (without secrets)
691
+ const safeWebhooks = this.webhooks.map((w) => ({
692
+ url: w.url,
693
+ events: w.events,
694
+ hasSecret: !!w.secret,
695
+ }));
696
+ return Response.json(safeWebhooks);
697
+ }
698
+
699
+ case 'POST': {
700
+ // Register a new webhook
701
+ const config = (await request.json()) as WebhookConfig;
702
+ if (!config.url || !config.events || config.events.length === 0) {
703
+ return new Response('Invalid webhook config', { status: 400 });
704
+ }
705
+
706
+ // Add webhook
707
+ this.webhooks.push(config);
708
+ await this.state.storage.put('webhooks', this.webhooks);
709
+
710
+ return Response.json({ status: 'registered', url: config.url });
711
+ }
712
+
713
+ case 'DELETE': {
714
+ // Remove a webhook by URL
715
+ const { url } = (await request.json()) as { url: string };
716
+ this.webhooks = this.webhooks.filter((w) => w.url !== url);
717
+ await this.state.storage.put('webhooks', this.webhooks);
718
+
719
+ return Response.json({ status: 'removed', url });
720
+ }
721
+
722
+ default:
723
+ return new Response('Method not allowed', { status: 405 });
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Handle version request
729
+ */
730
+ private async handleVersionRequest(request: Request): Promise<Response> {
731
+ if (request.method !== 'GET') {
732
+ return new Response('Method not allowed', { status: 405 });
733
+ }
734
+
735
+ const session = await this.getSession();
736
+ if (!session) {
737
+ return new Response('Not found', { status: 404 });
738
+ }
739
+
740
+ return Response.json({
741
+ version: session.version || 0,
742
+ updatedAt: session.updatedAt,
743
+ updatedBy: session.updatedBy,
744
+ schemaVersion: session.schema.version,
745
+ });
746
+ }
747
+
748
+ /**
749
+ * Fire webhooks for an event
750
+ */
751
+ private async fireWebhook(
752
+ event: WebhookPayload['event'],
753
+ session: PageSession,
754
+ prNumber?: number,
755
+ triggeredBy?: string,
756
+ ): Promise<void> {
757
+ const payload: WebhookPayload = {
758
+ event,
759
+ sessionId: this.state.id.toString(),
760
+ route: session.route,
761
+ version: session.version || 0,
762
+ timestamp: new Date().toISOString(),
763
+ prNumber,
764
+ triggeredBy,
765
+ };
766
+
767
+ // Fire to registered webhooks
768
+ const eventType = event.split('.')[1] as
769
+ | 'edit'
770
+ | 'publish'
771
+ | 'merge'
772
+ | 'all';
773
+ const relevantWebhooks = this.webhooks.filter(
774
+ (w) => w.events.includes('all') || w.events.includes(eventType as any),
775
+ );
776
+
777
+ const webhookPromises = relevantWebhooks.map(async (webhook) => {
778
+ try {
779
+ const headers: Record<string, string> = {
780
+ 'Content-Type': 'application/json',
781
+ 'X-Aeon-Event': event,
782
+ 'X-Aeon-Session': this.state.id.toString(),
783
+ };
784
+
785
+ // Add HMAC signature if secret is configured
786
+ if (webhook.secret) {
787
+ const body = JSON.stringify(payload);
788
+ const encoder = new TextEncoder();
789
+ const key = await crypto.subtle.importKey(
790
+ 'raw',
791
+ encoder.encode(webhook.secret),
792
+ { name: 'HMAC', hash: 'SHA-256' },
793
+ false,
794
+ ['sign'],
795
+ );
796
+ const sig = await crypto.subtle.sign(
797
+ 'HMAC',
798
+ key,
799
+ encoder.encode(body),
800
+ );
801
+ headers['X-Aeon-Signature'] = Array.from(new Uint8Array(sig))
802
+ .map((b) => b.toString(16).padStart(2, '0'))
803
+ .join('');
804
+ }
805
+
806
+ await fetch(webhook.url, {
807
+ method: 'POST',
808
+ headers,
809
+ body: JSON.stringify(payload),
810
+ });
811
+ } catch (err) {
812
+ console.error(`Failed to fire webhook to ${webhook.url}:`, err);
813
+ }
814
+ });
815
+
816
+ // Also fire to env-configured sync webhook
817
+ if (this.env.SYNC_WEBHOOK_URL) {
818
+ webhookPromises.push(
819
+ fetch(this.env.SYNC_WEBHOOK_URL, {
820
+ method: 'POST',
821
+ headers: {
822
+ 'Content-Type': 'application/json',
823
+ 'X-Aeon-Event': event,
824
+ 'X-Aeon-Session': this.state.id.toString(),
825
+ },
826
+ body: JSON.stringify(payload),
827
+ })
828
+ .then(() => {})
829
+ .catch((err) => console.error('Failed to fire sync webhook:', err)),
830
+ );
831
+ }
832
+
833
+ // Fire all webhooks in parallel, don't wait
834
+ this.state.waitUntil(Promise.all(webhookPromises));
835
+ }
836
+
837
+ private async handleSessionRequest(request: Request): Promise<Response> {
838
+ switch (request.method) {
839
+ case 'GET': {
840
+ const session = await this.getSession();
841
+ if (!session) {
842
+ return new Response('Not found', { status: 404 });
843
+ }
844
+ return Response.json(session);
845
+ }
846
+
847
+ case 'PUT': {
848
+ const session = (await request.json()) as PageSession;
849
+ await this.saveSession(session);
850
+ return new Response('OK', { status: 200 });
851
+ }
852
+
853
+ default:
854
+ return new Response('Method not allowed', { status: 405 });
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Handle session initialization (POST /init)
860
+ * Creates a new session or returns existing one
861
+ */
862
+ private async handleInitRequest(request: Request): Promise<Response> {
863
+ if (request.method !== 'POST') {
864
+ return new Response('Method not allowed', { status: 405 });
865
+ }
866
+
867
+ try {
868
+ const body = (await request.json()) as PageSession;
869
+
870
+ // Check if session already exists
871
+ const existing = await this.getSession();
872
+ if (existing) {
873
+ return Response.json({ status: 'exists', session: existing });
874
+ }
875
+
876
+ // Create new session
877
+ const session: PageSession = {
878
+ route: body.route || '/',
879
+ tree: body.tree || { type: 'div', props: {}, children: [] },
880
+ data: body.data || {},
881
+ schema: body.schema || { version: '1.0.0' },
882
+ version: 1,
883
+ updatedAt: new Date().toISOString(),
884
+ presence: [],
885
+ };
886
+
887
+ await this.saveSession(session, 'bootstrap', false);
888
+
889
+ return Response.json({ status: 'created', session });
890
+ } catch (err) {
891
+ console.error('Failed to initialize session:', err);
892
+ return new Response(
893
+ JSON.stringify({
894
+ error: 'Failed to initialize session',
895
+ message: err instanceof Error ? err.message : 'Unknown error',
896
+ }),
897
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
898
+ );
899
+ }
900
+ }
901
+
902
+ private async handleTreeRequest(request: Request): Promise<Response> {
903
+ switch (request.method) {
904
+ case 'GET': {
905
+ const tree = await this.state.storage.get<SerializedComponent>('tree');
906
+ if (!tree) {
907
+ return new Response('Not found', { status: 404 });
908
+ }
909
+ return Response.json(tree);
910
+ }
911
+
912
+ case 'PUT': {
913
+ const tree = (await request.json()) as SerializedComponent;
914
+ await this.state.storage.put('tree', tree);
915
+ return new Response('OK', { status: 200 });
916
+ }
917
+
918
+ default:
919
+ return new Response('Method not allowed', { status: 405 });
920
+ }
921
+ }
922
+
923
+ private async handlePresenceRequest(_request: Request): Promise<Response> {
924
+ return Response.json(Array.from(this.sessions.values()));
925
+ }
926
+
927
+ private async getSession(): Promise<PageSession | null> {
928
+ if (this.session) return this.session;
929
+
930
+ const stored = await this.state.storage.get<PageSession>('session');
931
+ if (stored) {
932
+ this.session = stored;
933
+ }
934
+ return this.session;
935
+ }
936
+
937
+ private async saveSession(
938
+ session: PageSession,
939
+ triggeredBy?: string,
940
+ fireWebhooks = true,
941
+ ): Promise<void> {
942
+ // Increment version
943
+ session.version = (session.version || 0) + 1;
944
+ session.updatedAt = new Date().toISOString();
945
+ if (triggeredBy) {
946
+ session.updatedBy = triggeredBy;
947
+ }
948
+
949
+ this.session = session;
950
+ await this.state.storage.put('session', session);
951
+
952
+ // Fire webhooks for session update
953
+ if (fireWebhooks) {
954
+ await this.fireWebhook(
955
+ 'session.updated',
956
+ session,
957
+ undefined,
958
+ triggeredBy,
959
+ );
960
+ }
961
+ }
962
+
963
+ // ============================================================================
964
+ // Sync Queue Endpoints (for offline-first support)
965
+ // ============================================================================
966
+
967
+ /**
968
+ * Handle sync queue batch (POST /sync-queue)
969
+ * Receives a batch of offline operations to sync
970
+ */
971
+ private async handleSyncQueueRequest(request: Request): Promise<Response> {
972
+ if (request.method !== 'POST') {
973
+ return new Response('Method not allowed', { status: 405 });
974
+ }
975
+
976
+ try {
977
+ const batch = (await request.json()) as {
978
+ batchId: string;
979
+ operations: Array<{
980
+ operationId: string;
981
+ type: string;
982
+ sessionId: string;
983
+ data: Record<string, unknown>;
984
+ timestamp: number;
985
+ }>;
986
+ };
987
+
988
+ const synced: string[] = [];
989
+ const failed: Array<{
990
+ operationId: string;
991
+ error: string;
992
+ retryable: boolean;
993
+ }> = [];
994
+ const conflicts: Array<{
995
+ operationId: string;
996
+ remoteVersion: Record<string, unknown>;
997
+ strategy: string;
998
+ }> = [];
999
+
1000
+ // Process each operation
1001
+ for (const op of batch.operations) {
1002
+ try {
1003
+ // Check for conflicts with current session state
1004
+ const session = await this.getSession();
1005
+ if (
1006
+ session &&
1007
+ (op.type === 'session_update' || op.type === 'tree_update')
1008
+ ) {
1009
+ // Simple last-write-wins for now
1010
+ // More sophisticated CRDT-based resolution could be added
1011
+ const currentVersion = session.version || 0;
1012
+ const opVersion = (op.data as { version?: number })?.version || 0;
1013
+
1014
+ if (opVersion < currentVersion) {
1015
+ conflicts.push({
1016
+ operationId: op.operationId,
1017
+ remoteVersion: {
1018
+ version: currentVersion,
1019
+ updatedAt: session.updatedAt || '',
1020
+ },
1021
+ strategy: 'remote-wins',
1022
+ });
1023
+ continue;
1024
+ }
1025
+ }
1026
+
1027
+ // Apply the operation
1028
+ if (op.type === 'session_update') {
1029
+ const currentSession = await this.getSession();
1030
+ if (currentSession) {
1031
+ const newSession = { ...currentSession, ...op.data };
1032
+ await this.saveSession(
1033
+ newSession as PageSession,
1034
+ 'sync-queue',
1035
+ true,
1036
+ );
1037
+ }
1038
+ } else if (op.type === 'tree_update') {
1039
+ const tree = op.data as unknown as SerializedComponent;
1040
+ await this.state.storage.put('tree', tree);
1041
+ } else if (op.type === 'data_update') {
1042
+ const session = await this.getSession();
1043
+ if (session) {
1044
+ session.data = { ...session.data, ...op.data };
1045
+ await this.saveSession(session, 'sync-queue', true);
1046
+ }
1047
+ }
1048
+
1049
+ synced.push(op.operationId);
1050
+ } catch (err) {
1051
+ failed.push({
1052
+ operationId: op.operationId,
1053
+ error: err instanceof Error ? err.message : 'Unknown error',
1054
+ retryable: true,
1055
+ });
1056
+ }
1057
+ }
1058
+
1059
+ // Store sync record for audit
1060
+ await this.state.storage.put(`sync:${batch.batchId}`, {
1061
+ batchId: batch.batchId,
1062
+ processedAt: Date.now(),
1063
+ synced: synced.length,
1064
+ failed: failed.length,
1065
+ conflicts: conflicts.length,
1066
+ });
1067
+
1068
+ return Response.json({
1069
+ success: failed.length === 0,
1070
+ synced,
1071
+ failed,
1072
+ conflicts,
1073
+ serverTimestamp: Date.now(),
1074
+ });
1075
+ } catch (err) {
1076
+ console.error('Failed to process sync queue:', err);
1077
+ return new Response(
1078
+ JSON.stringify({
1079
+ error: 'Failed to process sync queue',
1080
+ message: err instanceof Error ? err.message : 'Unknown error',
1081
+ }),
1082
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
1083
+ );
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Handle queue status request (GET /queue-status)
1089
+ * Returns pending operations for this session
1090
+ */
1091
+ private async handleQueueStatusRequest(request: Request): Promise<Response> {
1092
+ if (request.method !== 'GET') {
1093
+ return new Response('Method not allowed', { status: 405 });
1094
+ }
1095
+
1096
+ try {
1097
+ // Get all stored sync records
1098
+ const syncRecords = await this.state.storage.list<{
1099
+ batchId: string;
1100
+ processedAt: number;
1101
+ synced: number;
1102
+ failed: number;
1103
+ conflicts: number;
1104
+ }>({ prefix: 'sync:' });
1105
+
1106
+ // Get pending conflicts
1107
+ const conflicts = await this.state.storage.list<{
1108
+ conflictId: string;
1109
+ operationId: string;
1110
+ localData: Record<string, unknown>;
1111
+ remoteData: Record<string, unknown>;
1112
+ detectedAt: number;
1113
+ }>({ prefix: 'conflict:' });
1114
+
1115
+ const unresolvedConflicts = Array.from(conflicts.values()).filter(
1116
+ (c) => !(c as { resolved?: boolean }).resolved,
1117
+ );
1118
+
1119
+ return Response.json({
1120
+ pendingOperations: 0, // Operations are processed immediately
1121
+ recentSyncs: Array.from(syncRecords.values()).slice(-10),
1122
+ unresolvedConflicts: unresolvedConflicts.length,
1123
+ conflicts: unresolvedConflicts,
1124
+ });
1125
+ } catch (err) {
1126
+ console.error('Failed to get queue status:', err);
1127
+ return new Response(
1128
+ JSON.stringify({ error: 'Failed to get queue status' }),
1129
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
1130
+ );
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * Handle conflict resolution (POST /resolve-conflict)
1136
+ * Manually resolve a detected conflict
1137
+ */
1138
+ private async handleResolveConflictRequest(
1139
+ request: Request,
1140
+ ): Promise<Response> {
1141
+ if (request.method !== 'POST') {
1142
+ return new Response('Method not allowed', { status: 405 });
1143
+ }
1144
+
1145
+ try {
1146
+ const { conflictId, strategy, resolvedData, resolvedBy } =
1147
+ (await request.json()) as {
1148
+ conflictId: string;
1149
+ strategy: 'local-wins' | 'remote-wins' | 'merge' | 'manual';
1150
+ resolvedData?: Record<string, unknown>;
1151
+ resolvedBy?: string;
1152
+ };
1153
+
1154
+ // Get the conflict
1155
+ const conflict = await this.state.storage.get<{
1156
+ conflictId: string;
1157
+ operationId: string;
1158
+ localData: Record<string, unknown>;
1159
+ remoteData: Record<string, unknown>;
1160
+ detectedAt: number;
1161
+ }>(`conflict:${conflictId}`);
1162
+
1163
+ if (!conflict) {
1164
+ return new Response(JSON.stringify({ error: 'Conflict not found' }), {
1165
+ status: 404,
1166
+ headers: { 'Content-Type': 'application/json' },
1167
+ });
1168
+ }
1169
+
1170
+ // Determine resolved data based on strategy
1171
+ let finalData: Record<string, unknown>;
1172
+ switch (strategy) {
1173
+ case 'local-wins':
1174
+ finalData = conflict.localData;
1175
+ break;
1176
+ case 'remote-wins':
1177
+ finalData = conflict.remoteData;
1178
+ break;
1179
+ case 'merge':
1180
+ finalData = { ...conflict.remoteData, ...conflict.localData };
1181
+ break;
1182
+ case 'manual':
1183
+ if (!resolvedData) {
1184
+ return new Response(
1185
+ JSON.stringify({
1186
+ error: 'resolvedData required for manual strategy',
1187
+ }),
1188
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
1189
+ );
1190
+ }
1191
+ finalData = resolvedData;
1192
+ break;
1193
+ }
1194
+
1195
+ // Apply the resolution
1196
+ const session = await this.getSession();
1197
+ if (session) {
1198
+ session.data = { ...session.data, ...finalData };
1199
+ await this.saveSession(
1200
+ session,
1201
+ resolvedBy || 'conflict-resolution',
1202
+ true,
1203
+ );
1204
+ }
1205
+
1206
+ // Mark conflict as resolved
1207
+ await this.state.storage.put(`conflict:${conflictId}`, {
1208
+ ...conflict,
1209
+ resolved: true,
1210
+ resolution: {
1211
+ strategy,
1212
+ resolvedData: finalData,
1213
+ resolvedAt: Date.now(),
1214
+ resolvedBy,
1215
+ },
1216
+ });
1217
+
1218
+ // Broadcast resolution to connected clients
1219
+ this.broadcast({
1220
+ type: 'conflict-resolved',
1221
+ payload: {
1222
+ conflictId,
1223
+ strategy,
1224
+ resolvedData: finalData,
1225
+ },
1226
+ });
1227
+
1228
+ return Response.json({
1229
+ success: true,
1230
+ conflictId,
1231
+ strategy,
1232
+ resolvedData: finalData,
1233
+ });
1234
+ } catch (err) {
1235
+ console.error('Failed to resolve conflict:', err);
1236
+ return new Response(
1237
+ JSON.stringify({
1238
+ error: 'Failed to resolve conflict',
1239
+ message: err instanceof Error ? err.message : 'Unknown error',
1240
+ }),
1241
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
1242
+ );
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ /**
1248
+ * Aeon Routes Registry Durable Object
1249
+ *
1250
+ * Singleton that manages the route registry with strong consistency.
1251
+ * Used via: namespace.idFromName('__routes__')
1252
+ */
1253
+ export class AeonRoutesRegistry {
1254
+ private state: DurableObjectState;
1255
+ private env: Env;
1256
+
1257
+ constructor(state: DurableObjectState, env: Env) {
1258
+ this.state = state;
1259
+ this.env = env;
1260
+ }
1261
+
1262
+ async fetch(request: Request): Promise<Response> {
1263
+ const url = new URL(request.url);
1264
+
1265
+ switch (url.pathname) {
1266
+ case '/route':
1267
+ return this.handleRouteRequest(request);
1268
+ case '/routes':
1269
+ return this.handleRoutesRequest(request);
1270
+ default:
1271
+ return new Response('Not found', { status: 404 });
1272
+ }
1273
+ }
1274
+
1275
+ private async handleRouteRequest(request: Request): Promise<Response> {
1276
+ switch (request.method) {
1277
+ case 'POST': {
1278
+ // Get route by path
1279
+ const { path } = (await request.json()) as {
1280
+ action: string;
1281
+ path: string;
1282
+ };
1283
+ const route = await this.state.storage.get(`route:${path}`);
1284
+ if (!route) {
1285
+ return new Response('Not found', { status: 404 });
1286
+ }
1287
+ return Response.json(route);
1288
+ }
1289
+
1290
+ case 'PUT': {
1291
+ // Save route
1292
+ const route = (await request.json()) as { pattern: string };
1293
+ await this.state.storage.put(`route:${route.pattern}`, route);
1294
+ return new Response('OK', { status: 200 });
1295
+ }
1296
+
1297
+ case 'DELETE': {
1298
+ // Delete route
1299
+ const { path } = (await request.json()) as { path: string };
1300
+ await this.state.storage.delete(`route:${path}`);
1301
+ return new Response('OK', { status: 200 });
1302
+ }
1303
+
1304
+ default:
1305
+ return new Response('Method not allowed', { status: 405 });
1306
+ }
1307
+ }
1308
+
1309
+ private async handleRoutesRequest(request: Request): Promise<Response> {
1310
+ if (request.method !== 'GET') {
1311
+ return new Response('Method not allowed', { status: 405 });
1312
+ }
1313
+
1314
+ const routes = await this.state.storage.list({ prefix: 'route:' });
1315
+ return Response.json(Array.from(routes.values()));
1316
+ }
1317
+ }
1318
+
1319
+ // Type augmentations for Cloudflare Workers runtime
1320
+ interface DurableObjectState {
1321
+ storage: DurableObjectStorage;
1322
+ id: DurableObjectId;
1323
+ waitUntil(promise: Promise<unknown>): void;
1324
+ blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T>;
1325
+ }
1326
+
1327
+ interface DurableObjectStorage {
1328
+ get<T = unknown>(key: string): Promise<T | undefined>;
1329
+ put<T>(key: string, value: T): Promise<void>;
1330
+ delete(key: string): Promise<boolean>;
1331
+ list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
1332
+ }
1333
+
1334
+ interface DurableObjectId {
1335
+ toString(): string;
1336
+ }
1337
+
1338
+ declare class WebSocketPair {
1339
+ 0: WebSocket;
1340
+ 1: WebSocket;
1341
+ }