@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,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
|
+
}
|