@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,682 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { mkdir, writeFile, rm, readFile, readdir } from 'fs/promises';
3
+ import { join, resolve } from 'path';
4
+ import { build } from './build';
5
+
6
+ describe('aeon build', () => {
7
+ const originalCwd = process.cwd();
8
+ const testDir = resolve(originalCwd, '.aeon-test-build');
9
+ const pagesDir = join(testDir, 'pages');
10
+ const outputDir = join(testDir, '.aeon');
11
+
12
+ beforeEach(async () => {
13
+ // Clean up any existing test dir
14
+ await rm(testDir, { recursive: true, force: true });
15
+
16
+ // Create test directory structure
17
+ await mkdir(pagesDir, { recursive: true });
18
+ await mkdir(join(pagesDir, 'blog', '[slug]'), { recursive: true });
19
+ await mkdir(join(pagesDir, 'docs', '[[...path]]'), { recursive: true });
20
+ await mkdir(join(pagesDir, 'api', '[...catchall]'), { recursive: true });
21
+
22
+ // Create test pages
23
+ await writeFile(
24
+ join(pagesDir, 'page.tsx'),
25
+ `'use aeon';
26
+
27
+ export default function Home() {
28
+ return (
29
+ <div className="container">
30
+ <h1>Welcome to Aeon Flux</h1>
31
+ <p>The CMS is the website.</p>
32
+ </div>
33
+ );
34
+ }`,
35
+ );
36
+
37
+ await writeFile(
38
+ join(pagesDir, 'blog', '[slug]', 'page.tsx'),
39
+ `'use aeon';
40
+
41
+ export default function BlogPost({ params }) {
42
+ return (
43
+ <article>
44
+ <h1>Blog Post</h1>
45
+ </article>
46
+ );
47
+ }`,
48
+ );
49
+
50
+ await writeFile(
51
+ join(pagesDir, 'blog', '[slug]', 'layout.tsx'),
52
+ `export default function BlogLayout({ children }) {
53
+ return <div className="blog-layout">{children}</div>;
54
+ }`,
55
+ );
56
+
57
+ await writeFile(
58
+ join(pagesDir, 'docs', '[[...path]]', 'page.tsx'),
59
+ `"use aeon";
60
+
61
+ export default function Docs() {
62
+ return (
63
+ <section>
64
+ <h1>Documentation</h1>
65
+ </section>
66
+ );
67
+ }`,
68
+ );
69
+
70
+ await writeFile(
71
+ join(pagesDir, 'api', '[...catchall]', 'page.ts'),
72
+ `// Static page - no directive
73
+ export default function ApiDocs() {
74
+ return { type: 'div', children: ['API Docs'] };
75
+ }`,
76
+ );
77
+
78
+ // Create config
79
+ await writeFile(
80
+ join(testDir, 'aeon.config.ts'),
81
+ `export default {
82
+ pagesDir: './pages',
83
+ runtime: 'cloudflare',
84
+ output: { dir: '.aeon' },
85
+ };`,
86
+ );
87
+
88
+ // Change to test directory
89
+ process.chdir(testDir);
90
+ });
91
+
92
+ afterEach(async () => {
93
+ process.chdir(originalCwd);
94
+ await rm(testDir, { recursive: true, force: true });
95
+ });
96
+
97
+ test('generates manifest.json with all routes', async () => {
98
+ await build({});
99
+
100
+ const manifest = JSON.parse(
101
+ await readFile(join(outputDir, 'manifest.json'), 'utf-8'),
102
+ );
103
+
104
+ expect(manifest.version).toBe('1.0.0');
105
+ expect(manifest.routes.length).toBeGreaterThanOrEqual(4);
106
+
107
+ // Check root route
108
+ const rootRoute = manifest.routes.find((r: any) => r.pattern === '/');
109
+ expect(rootRoute).toBeDefined();
110
+ expect(rootRoute.isAeon).toBe(true);
111
+ expect(rootRoute.sessionId).toBe('index');
112
+
113
+ // Check dynamic route
114
+ const blogRoute = manifest.routes.find((r: any) =>
115
+ r.pattern.includes('[slug]'),
116
+ );
117
+ expect(blogRoute).toBeDefined();
118
+ expect(blogRoute.isAeon).toBe(true);
119
+
120
+ // Check optional catch-all
121
+ const docsRoute = manifest.routes.find((r: any) =>
122
+ r.pattern.includes('[[...path]]'),
123
+ );
124
+ expect(docsRoute).toBeDefined();
125
+
126
+ // Check non-aeon route
127
+ const apiRoute = manifest.routes.find((r: any) =>
128
+ r.pattern.includes('[...catchall]'),
129
+ );
130
+ expect(apiRoute).toBeDefined();
131
+ expect(apiRoute.isAeon).toBe(false);
132
+ });
133
+
134
+ test('generates D1 migration SQL', async () => {
135
+ await build({});
136
+
137
+ const migration = await readFile(
138
+ join(outputDir, 'migrations', '0001_initial.sql'),
139
+ 'utf-8',
140
+ );
141
+
142
+ // Check for tables
143
+ expect(migration).toContain('CREATE TABLE IF NOT EXISTS routes');
144
+ expect(migration).toContain('CREATE TABLE IF NOT EXISTS sessions');
145
+ expect(migration).toContain('CREATE TABLE IF NOT EXISTS presence');
146
+
147
+ // Check for indexes
148
+ expect(migration).toContain(
149
+ 'CREATE INDEX IF NOT EXISTS idx_routes_pattern',
150
+ );
151
+ expect(migration).toContain(
152
+ 'CREATE INDEX IF NOT EXISTS idx_sessions_route',
153
+ );
154
+ expect(migration).toContain(
155
+ 'CREATE INDEX IF NOT EXISTS idx_presence_session',
156
+ );
157
+
158
+ // Check columns
159
+ expect(migration).toContain('path TEXT PRIMARY KEY');
160
+ expect(migration).toContain('session_id TEXT');
161
+ expect(migration).toContain('tree TEXT NOT NULL');
162
+ expect(migration).toContain('schema_version TEXT');
163
+ });
164
+
165
+ test('generates seed.sql with route and session data', async () => {
166
+ await build({});
167
+
168
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
169
+
170
+ // Check for route insertions
171
+ expect(seed).toContain('INSERT OR REPLACE INTO routes');
172
+ expect(seed).toContain('pattern, session_id, component_id');
173
+
174
+ // Check for session insertions
175
+ expect(seed).toContain('INSERT OR REPLACE INTO sessions');
176
+ expect(seed).toContain('tree');
177
+ expect(seed).toContain('schema_version');
178
+
179
+ // Check that component tree is serialized
180
+ expect(seed).toContain('"type"');
181
+ expect(seed).toContain('"children"');
182
+ });
183
+
184
+ test('generates Cloudflare Worker', async () => {
185
+ await build({});
186
+
187
+ const worker = await readFile(
188
+ join(outputDir, 'dist', 'worker.js'),
189
+ 'utf-8',
190
+ );
191
+
192
+ // Check for route matching
193
+ expect(worker).toContain('const ROUTES =');
194
+ expect(worker).toContain('matchRoute');
195
+ expect(worker).toContain('matchPattern');
196
+
197
+ // Check for D1 integration
198
+ expect(worker).toContain('getSession');
199
+ expect(worker).toContain('env.DB');
200
+ expect(worker).toContain('.prepare(');
201
+
202
+ // Check for WebSocket handling
203
+ expect(worker).toContain('handleWebSocket');
204
+ expect(worker).toContain('AEON_SESSIONS');
205
+
206
+ // Check for rendering
207
+ expect(worker).toContain('renderPage');
208
+ expect(worker).toContain('renderTree');
209
+
210
+ // Check for dynamic segment handling
211
+ expect(worker).toContain('[[...');
212
+ expect(worker).toContain('[...');
213
+ });
214
+
215
+ test('generates wrangler.toml', async () => {
216
+ await build({});
217
+
218
+ const wrangler = await readFile(join(outputDir, 'wrangler.toml'), 'utf-8');
219
+
220
+ expect(wrangler).toContain('name = "aeon-flux"');
221
+ expect(wrangler).toContain('main = "dist/worker.js"');
222
+ expect(wrangler).toContain('[[d1_databases]]');
223
+ expect(wrangler).toContain('binding = "DB"');
224
+ expect(wrangler).toContain('[durable_objects]');
225
+ expect(wrangler).toContain('AeonPageSession');
226
+ });
227
+
228
+ test('parses JSX and extracts component tree', async () => {
229
+ await build({});
230
+
231
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
232
+
233
+ // Check that JSX was parsed into tree structure
234
+ // The home page has a div with className "container"
235
+ expect(seed).toContain('"type"');
236
+
237
+ // Check for text content extraction
238
+ expect(seed.toLowerCase()).toContain('welcome');
239
+ });
240
+
241
+ test('detects use aeon directive with single quotes', async () => {
242
+ await build({});
243
+
244
+ const manifest = JSON.parse(
245
+ await readFile(join(outputDir, 'manifest.json'), 'utf-8'),
246
+ );
247
+
248
+ const homeRoute = manifest.routes.find((r: any) => r.pattern === '/');
249
+ expect(homeRoute.isAeon).toBe(true);
250
+ });
251
+
252
+ test('detects use aeon directive with double quotes', async () => {
253
+ await build({});
254
+
255
+ const manifest = JSON.parse(
256
+ await readFile(join(outputDir, 'manifest.json'), 'utf-8'),
257
+ );
258
+
259
+ const docsRoute = manifest.routes.find((r: any) =>
260
+ r.pattern.includes('docs'),
261
+ );
262
+ expect(docsRoute.isAeon).toBe(true);
263
+ });
264
+
265
+ test('detects non-aeon pages', async () => {
266
+ await build({});
267
+
268
+ const manifest = JSON.parse(
269
+ await readFile(join(outputDir, 'manifest.json'), 'utf-8'),
270
+ );
271
+
272
+ const apiRoute = manifest.routes.find((r: any) =>
273
+ r.pattern.includes('api'),
274
+ );
275
+ expect(apiRoute.isAeon).toBe(false);
276
+ });
277
+
278
+ test('handles layout files', async () => {
279
+ await build({});
280
+
281
+ const manifest = JSON.parse(
282
+ await readFile(join(outputDir, 'manifest.json'), 'utf-8'),
283
+ );
284
+
285
+ const blogRoute = manifest.routes.find((r: any) =>
286
+ r.pattern.includes('[slug]'),
287
+ );
288
+ expect(blogRoute.layout).toBeDefined();
289
+ });
290
+
291
+ test('creates correct output directory structure', async () => {
292
+ await build({});
293
+
294
+ const distFiles = await readdir(join(outputDir, 'dist'));
295
+ expect(distFiles).toContain('worker.js');
296
+
297
+ const migrationFiles = await readdir(join(outputDir, 'migrations'));
298
+ expect(migrationFiles).toContain('0001_initial.sql');
299
+
300
+ const rootFiles = await readdir(outputDir);
301
+ expect(rootFiles).toContain('manifest.json');
302
+ expect(rootFiles).toContain('seed.sql');
303
+ expect(rootFiles).toContain('wrangler.toml');
304
+ });
305
+
306
+ test('generates valid SQL for seed data', async () => {
307
+ await build({});
308
+
309
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
310
+
311
+ // Check SQL syntax
312
+ const statements = seed.split(';').filter((s) => s.trim());
313
+ for (const stmt of statements) {
314
+ if (stmt.includes('INSERT')) {
315
+ expect(stmt).toMatch(/INSERT OR REPLACE INTO \w+ \([^)]+\) VALUES/);
316
+ }
317
+ }
318
+
319
+ // Check for proper escaping
320
+ expect(seed).not.toContain("''"); // No double single quotes unless escaping
321
+ });
322
+
323
+ test('handles empty pages directory gracefully', async () => {
324
+ // Remove all pages
325
+ await rm(pagesDir, { recursive: true });
326
+ await mkdir(pagesDir, { recursive: true });
327
+
328
+ await build({});
329
+
330
+ const manifest = JSON.parse(
331
+ await readFile(join(outputDir, 'manifest.json'), 'utf-8'),
332
+ );
333
+
334
+ expect(manifest.routes).toEqual([]);
335
+ });
336
+
337
+ test('uses default config when config file missing', async () => {
338
+ // Remove config
339
+ await rm(join(testDir, 'aeon.config.ts'));
340
+
341
+ // Create pages in default location
342
+ await mkdir(join(testDir, 'pages'), { recursive: true });
343
+ await writeFile(
344
+ join(testDir, 'pages', 'page.tsx'),
345
+ `'use aeon';\nexport default function Page() { return <div>Test</div>; }`,
346
+ );
347
+
348
+ await build({ config: 'nonexistent.config.ts' });
349
+
350
+ // Should use defaults and still work
351
+ const manifest = JSON.parse(
352
+ await readFile(join(testDir, '.aeon', 'manifest.json'), 'utf-8'),
353
+ );
354
+ expect(manifest.version).toBe('1.0.0');
355
+ });
356
+ });
357
+
358
+ describe('AST to D1 sync', () => {
359
+ const originalCwd = process.cwd();
360
+ const testDir = resolve(originalCwd, '.aeon-test-ast-sync');
361
+ const pagesDir = join(testDir, 'pages');
362
+ const outputDir = join(testDir, '.aeon');
363
+
364
+ beforeEach(async () => {
365
+ await rm(testDir, { recursive: true, force: true });
366
+ await mkdir(pagesDir, { recursive: true });
367
+ await writeFile(
368
+ join(testDir, 'aeon.config.ts'),
369
+ `export default { pagesDir: './pages', runtime: 'cloudflare' };`,
370
+ );
371
+ process.chdir(testDir);
372
+ });
373
+
374
+ afterEach(async () => {
375
+ process.chdir(originalCwd);
376
+ await rm(testDir, { recursive: true, force: true });
377
+ });
378
+
379
+ test('extracts component type from JSX', async () => {
380
+ await writeFile(
381
+ join(pagesDir, 'page.tsx'),
382
+ `'use aeon';
383
+ export default function Test() {
384
+ return (
385
+ <main className="test">
386
+ <h1>Title</h1>
387
+ </main>
388
+ );
389
+ }`,
390
+ );
391
+
392
+ await build({});
393
+
394
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
395
+ expect(seed).toContain('"type":"main"');
396
+ });
397
+
398
+ test('extracts text content from JSX', async () => {
399
+ await writeFile(
400
+ join(pagesDir, 'page.tsx'),
401
+ `'use aeon';
402
+ export default function Test() {
403
+ return (
404
+ <div>
405
+ Hello World Content Here
406
+ </div>
407
+ );
408
+ }`,
409
+ );
410
+
411
+ await build({});
412
+
413
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
414
+ expect(seed.toLowerCase()).toContain('hello world');
415
+ });
416
+
417
+ test('handles component with props', async () => {
418
+ await writeFile(
419
+ join(pagesDir, 'page.tsx'),
420
+ `'use aeon';
421
+ export default function Test() {
422
+ return (
423
+ <section id="main" data-testid="container">
424
+ Content
425
+ </section>
426
+ );
427
+ }`,
428
+ );
429
+
430
+ await build({});
431
+
432
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
433
+ expect(seed).toContain('"type":"section"');
434
+ expect(seed).toContain('"props"');
435
+ expect(seed).toContain('"className":"aeon-page"');
436
+ });
437
+
438
+ test('handles nested components', async () => {
439
+ await writeFile(
440
+ join(pagesDir, 'page.tsx'),
441
+ `'use aeon';
442
+ export default function Test() {
443
+ return (
444
+ <div>
445
+ <header>
446
+ <nav>Navigation</nav>
447
+ </header>
448
+ <main>Content</main>
449
+ </div>
450
+ );
451
+ }`,
452
+ );
453
+
454
+ await build({});
455
+
456
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
457
+ expect(seed).toContain('"type":"div"');
458
+ });
459
+
460
+ test('syncs route pattern to session ID', async () => {
461
+ await mkdir(join(pagesDir, 'users', '[id]', 'posts', '[postId]'), {
462
+ recursive: true,
463
+ });
464
+ await writeFile(
465
+ join(pagesDir, 'users', '[id]', 'posts', '[postId]', 'page.tsx'),
466
+ `'use aeon';
467
+ export default function Post() {
468
+ return <article>Post</article>;
469
+ }`,
470
+ );
471
+
472
+ await build({});
473
+
474
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
475
+
476
+ // Session ID should be derived from route
477
+ expect(seed).toContain('users-[id]-posts-[postId]');
478
+ });
479
+
480
+ test('handles special characters in content', async () => {
481
+ await writeFile(
482
+ join(pagesDir, 'page.tsx'),
483
+ `'use aeon';
484
+ export default function Test() {
485
+ return (
486
+ <div>
487
+ It's a "test" with <special> & characters
488
+ </div>
489
+ );
490
+ }`,
491
+ );
492
+
493
+ await build({});
494
+
495
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
496
+ // Should be properly escaped for SQL - single quotes are doubled
497
+ expect(seed).toContain("It''s"); // Single quote escaped as ''
498
+ expect(seed).toContain('test'); // Double quotes should be preserved in JSON
499
+ });
500
+
501
+ test('generates consistent session IDs', async () => {
502
+ await writeFile(
503
+ join(pagesDir, 'page.tsx'),
504
+ `'use aeon';\nexport default function Home() { return <div>Home</div>; }`,
505
+ );
506
+
507
+ await build({});
508
+
509
+ const manifest = JSON.parse(
510
+ await readFile(join(outputDir, 'manifest.json'), 'utf-8'),
511
+ );
512
+ const seed = await readFile(join(outputDir, 'seed.sql'), 'utf-8');
513
+
514
+ const homeRoute = manifest.routes.find((r: any) => r.pattern === '/');
515
+ expect(seed).toContain(`'${homeRoute.sessionId}'`);
516
+ });
517
+ });
518
+
519
+ describe('Multi-layer caching', () => {
520
+ const originalCwd = process.cwd();
521
+ const testDir = resolve(originalCwd, '.aeon-test-cache');
522
+ const pagesDir = join(testDir, 'pages');
523
+ const outputDir = join(testDir, '.aeon');
524
+
525
+ beforeEach(async () => {
526
+ await rm(testDir, { recursive: true, force: true });
527
+ await mkdir(pagesDir, { recursive: true });
528
+ await writeFile(
529
+ join(pagesDir, 'page.tsx'),
530
+ `'use aeon';\nexport default function Home() { return <div>Home</div>; }`,
531
+ );
532
+ await writeFile(
533
+ join(testDir, 'aeon.config.ts'),
534
+ `export default { pagesDir: './pages', runtime: 'cloudflare' };`,
535
+ );
536
+ process.chdir(testDir);
537
+ });
538
+
539
+ afterEach(async () => {
540
+ process.chdir(originalCwd);
541
+ await rm(testDir, { recursive: true, force: true });
542
+ });
543
+
544
+ test('wrangler.toml includes KV namespace for page cache', async () => {
545
+ await build({});
546
+
547
+ const wrangler = await readFile(join(outputDir, 'wrangler.toml'), 'utf-8');
548
+
549
+ expect(wrangler).toContain('[[kv_namespaces]]');
550
+ expect(wrangler).toContain('binding = "PAGES_CACHE"');
551
+ expect(wrangler).toContain('# KV Namespace - edge page cache');
552
+ });
553
+
554
+ test('worker includes KV cache layer logic', async () => {
555
+ await build({});
556
+
557
+ const worker = await readFile(
558
+ join(outputDir, 'dist', 'worker.js'),
559
+ 'utf-8',
560
+ );
561
+
562
+ // Check for KV cache first check
563
+ expect(worker).toContain('env.PAGES_CACHE');
564
+ expect(worker).toContain('getFromKV');
565
+ expect(worker).toContain("'X-Aeon-Cache': 'HIT-KV'");
566
+ });
567
+
568
+ test('worker includes D1 pre-rendered page layer', async () => {
569
+ await build({});
570
+
571
+ const worker = await readFile(
572
+ join(outputDir, 'dist', 'worker.js'),
573
+ 'utf-8',
574
+ );
575
+
576
+ expect(worker).toContain('getPreRenderedPage');
577
+ expect(worker).toContain("'X-Aeon-Cache': 'HIT-D1'");
578
+ expect(worker).toContain('rendered_pages');
579
+ });
580
+
581
+ test('worker includes session-based fallback layer', async () => {
582
+ await build({});
583
+
584
+ const worker = await readFile(
585
+ join(outputDir, 'dist', 'worker.js'),
586
+ 'utf-8',
587
+ );
588
+
589
+ expect(worker).toContain('getSession');
590
+ expect(worker).toContain("'X-Aeon-Cache': 'MISS'");
591
+ expect(worker).toContain('renderPage');
592
+ });
593
+
594
+ test('worker includes build version for cache invalidation', async () => {
595
+ await build({});
596
+
597
+ const worker = await readFile(
598
+ join(outputDir, 'dist', 'worker.js'),
599
+ 'utf-8',
600
+ );
601
+
602
+ expect(worker).toContain('BUILD_VERSION');
603
+ expect(worker).toContain('cached.version === BUILD_VERSION');
604
+ });
605
+
606
+ test('worker caches KV-miss to KV after D1 hit', async () => {
607
+ await build({});
608
+
609
+ const worker = await readFile(
610
+ join(outputDir, 'dist', 'worker.js'),
611
+ 'utf-8',
612
+ );
613
+
614
+ // After D1 hit, should cache in KV
615
+ expect(worker).toContain('ctx.waitUntil');
616
+ expect(worker).toContain('env.PAGES_CACHE.put');
617
+ expect(worker).toContain('expirationTtl: CACHE_TTL');
618
+ });
619
+
620
+ test('worker caches session-rendered pages to KV', async () => {
621
+ await build({});
622
+
623
+ const worker = await readFile(
624
+ join(outputDir, 'dist', 'worker.js'),
625
+ 'utf-8',
626
+ );
627
+
628
+ // After session render, should cache in KV
629
+ expect(worker).toContain('JSON.stringify(cacheData)');
630
+ expect(worker).toContain('html, version: BUILD_VERSION');
631
+ });
632
+
633
+ test('worker returns proper cache headers', async () => {
634
+ await build({});
635
+
636
+ const worker = await readFile(
637
+ join(outputDir, 'dist', 'worker.js'),
638
+ 'utf-8',
639
+ );
640
+
641
+ // Check for cache control headers
642
+ expect(worker).toContain('Cache-Control');
643
+ expect(worker).toContain('max-age=3600');
644
+ expect(worker).toContain('stale-while-revalidate');
645
+ expect(worker).toContain('X-Aeon-Version');
646
+ });
647
+
648
+ test('getFromKV helper parses cached JSON correctly', async () => {
649
+ await build({});
650
+
651
+ const worker = await readFile(
652
+ join(outputDir, 'dist', 'worker.js'),
653
+ 'utf-8',
654
+ );
655
+
656
+ expect(worker).toContain('async function getFromKV');
657
+ expect(worker).toContain('JSON.parse(value)');
658
+ expect(worker).toContain('return null'); // Returns null on miss or parse error
659
+ });
660
+
661
+ test('cache key uses route pattern', async () => {
662
+ await build({});
663
+
664
+ const worker = await readFile(
665
+ join(outputDir, 'dist', 'worker.js'),
666
+ 'utf-8',
667
+ );
668
+
669
+ expect(worker).toContain('const cacheKey = `page:${match.pattern}`');
670
+ });
671
+
672
+ test('CACHE_TTL is configurable', async () => {
673
+ await build({});
674
+
675
+ const worker = await readFile(
676
+ join(outputDir, 'dist', 'worker.js'),
677
+ 'utf-8',
678
+ );
679
+
680
+ expect(worker).toContain('const CACHE_TTL = 3600');
681
+ });
682
+ });