AppView in a box as a Vite plugin thing hatk.dev
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: SvelteKit SSR support with server-side XRPC, OAuth sessions, and dev tooling

Rewrite server.ts to Request→Response handler, add SSR renderer and
Node.js adapter, implement session cookies for viewer resolution in
SSR, add /__dev/login endpoint for test auth, register core XRPC
handlers for preferences, fix OAuth scope joining, switch test context
to SQLite, and add /__dev/ route forwarding in vite plugin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+2459 -824
+706 -4
package-lock.json
··· 9 9 "packages/*", 10 10 "docs/site" 11 11 ], 12 + "dependencies": { 13 + "better-sqlite3": "^12.8.0" 14 + }, 12 15 "devDependencies": { 13 16 "@types/node": "^25.3.0", 14 17 "oxfmt": "^0.35.0", ··· 358 361 "win32" 359 362 ] 360 363 }, 364 + "node_modules/@emnapi/core": { 365 + "version": "1.9.0", 366 + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", 367 + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", 368 + "dev": true, 369 + "license": "MIT", 370 + "optional": true, 371 + "dependencies": { 372 + "@emnapi/wasi-threads": "1.2.0", 373 + "tslib": "^2.4.0" 374 + } 375 + }, 361 376 "node_modules/@emnapi/runtime": { 362 377 "version": "1.8.1", 363 378 "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", 364 379 "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", 380 + "license": "MIT", 381 + "optional": true, 382 + "dependencies": { 383 + "tslib": "^2.4.0" 384 + } 385 + }, 386 + "node_modules/@emnapi/wasi-threads": { 387 + "version": "1.2.0", 388 + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", 389 + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", 390 + "dev": true, 365 391 "license": "MIT", 366 392 "optional": true, 367 393 "dependencies": { ··· 1349 1375 "url": "https://opencollective.com/unified" 1350 1376 } 1351 1377 }, 1378 + "node_modules/@napi-rs/wasm-runtime": { 1379 + "version": "1.1.1", 1380 + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", 1381 + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", 1382 + "dev": true, 1383 + "license": "MIT", 1384 + "optional": true, 1385 + "dependencies": { 1386 + "@emnapi/core": "^1.7.1", 1387 + "@emnapi/runtime": "^1.7.1", 1388 + "@tybys/wasm-util": "^0.10.1" 1389 + }, 1390 + "funding": { 1391 + "type": "github", 1392 + "url": "https://github.com/sponsors/Brooooooklyn" 1393 + } 1394 + }, 1352 1395 "node_modules/@oslojs/encoding": { 1353 1396 "version": "1.1.0", 1354 1397 "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", 1355 1398 "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", 1356 1399 "license": "MIT" 1357 1400 }, 1401 + "node_modules/@oxc-project/runtime": { 1402 + "version": "0.115.0", 1403 + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", 1404 + "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", 1405 + "dev": true, 1406 + "license": "MIT", 1407 + "engines": { 1408 + "node": "^20.19.0 || >=22.12.0" 1409 + } 1410 + }, 1411 + "node_modules/@oxc-project/types": { 1412 + "version": "0.115.0", 1413 + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", 1414 + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", 1415 + "dev": true, 1416 + "license": "MIT", 1417 + "funding": { 1418 + "url": "https://github.com/sponsors/Boshen" 1419 + } 1420 + }, 1358 1421 "node_modules/@oxfmt/binding-android-arm-eabi": { 1359 1422 "version": "0.35.0", 1360 1423 "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.35.0.tgz", ··· 2316 2379 "node": ">= 10" 2317 2380 } 2318 2381 }, 2382 + "node_modules/@rolldown/binding-android-arm64": { 2383 + "version": "1.0.0-rc.9", 2384 + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", 2385 + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", 2386 + "cpu": [ 2387 + "arm64" 2388 + ], 2389 + "dev": true, 2390 + "license": "MIT", 2391 + "optional": true, 2392 + "os": [ 2393 + "android" 2394 + ], 2395 + "engines": { 2396 + "node": "^20.19.0 || >=22.12.0" 2397 + } 2398 + }, 2399 + "node_modules/@rolldown/binding-darwin-arm64": { 2400 + "version": "1.0.0-rc.9", 2401 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", 2402 + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", 2403 + "cpu": [ 2404 + "arm64" 2405 + ], 2406 + "dev": true, 2407 + "license": "MIT", 2408 + "optional": true, 2409 + "os": [ 2410 + "darwin" 2411 + ], 2412 + "engines": { 2413 + "node": "^20.19.0 || >=22.12.0" 2414 + } 2415 + }, 2416 + "node_modules/@rolldown/binding-darwin-x64": { 2417 + "version": "1.0.0-rc.9", 2418 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", 2419 + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", 2420 + "cpu": [ 2421 + "x64" 2422 + ], 2423 + "dev": true, 2424 + "license": "MIT", 2425 + "optional": true, 2426 + "os": [ 2427 + "darwin" 2428 + ], 2429 + "engines": { 2430 + "node": "^20.19.0 || >=22.12.0" 2431 + } 2432 + }, 2433 + "node_modules/@rolldown/binding-freebsd-x64": { 2434 + "version": "1.0.0-rc.9", 2435 + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", 2436 + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", 2437 + "cpu": [ 2438 + "x64" 2439 + ], 2440 + "dev": true, 2441 + "license": "MIT", 2442 + "optional": true, 2443 + "os": [ 2444 + "freebsd" 2445 + ], 2446 + "engines": { 2447 + "node": "^20.19.0 || >=22.12.0" 2448 + } 2449 + }, 2450 + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { 2451 + "version": "1.0.0-rc.9", 2452 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", 2453 + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", 2454 + "cpu": [ 2455 + "arm" 2456 + ], 2457 + "dev": true, 2458 + "license": "MIT", 2459 + "optional": true, 2460 + "os": [ 2461 + "linux" 2462 + ], 2463 + "engines": { 2464 + "node": "^20.19.0 || >=22.12.0" 2465 + } 2466 + }, 2467 + "node_modules/@rolldown/binding-linux-arm64-gnu": { 2468 + "version": "1.0.0-rc.9", 2469 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", 2470 + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", 2471 + "cpu": [ 2472 + "arm64" 2473 + ], 2474 + "dev": true, 2475 + "license": "MIT", 2476 + "optional": true, 2477 + "os": [ 2478 + "linux" 2479 + ], 2480 + "engines": { 2481 + "node": "^20.19.0 || >=22.12.0" 2482 + } 2483 + }, 2484 + "node_modules/@rolldown/binding-linux-arm64-musl": { 2485 + "version": "1.0.0-rc.9", 2486 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", 2487 + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", 2488 + "cpu": [ 2489 + "arm64" 2490 + ], 2491 + "dev": true, 2492 + "license": "MIT", 2493 + "optional": true, 2494 + "os": [ 2495 + "linux" 2496 + ], 2497 + "engines": { 2498 + "node": "^20.19.0 || >=22.12.0" 2499 + } 2500 + }, 2501 + "node_modules/@rolldown/binding-linux-ppc64-gnu": { 2502 + "version": "1.0.0-rc.9", 2503 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", 2504 + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", 2505 + "cpu": [ 2506 + "ppc64" 2507 + ], 2508 + "dev": true, 2509 + "license": "MIT", 2510 + "optional": true, 2511 + "os": [ 2512 + "linux" 2513 + ], 2514 + "engines": { 2515 + "node": "^20.19.0 || >=22.12.0" 2516 + } 2517 + }, 2518 + "node_modules/@rolldown/binding-linux-s390x-gnu": { 2519 + "version": "1.0.0-rc.9", 2520 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", 2521 + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", 2522 + "cpu": [ 2523 + "s390x" 2524 + ], 2525 + "dev": true, 2526 + "license": "MIT", 2527 + "optional": true, 2528 + "os": [ 2529 + "linux" 2530 + ], 2531 + "engines": { 2532 + "node": "^20.19.0 || >=22.12.0" 2533 + } 2534 + }, 2535 + "node_modules/@rolldown/binding-linux-x64-gnu": { 2536 + "version": "1.0.0-rc.9", 2537 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", 2538 + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", 2539 + "cpu": [ 2540 + "x64" 2541 + ], 2542 + "dev": true, 2543 + "license": "MIT", 2544 + "optional": true, 2545 + "os": [ 2546 + "linux" 2547 + ], 2548 + "engines": { 2549 + "node": "^20.19.0 || >=22.12.0" 2550 + } 2551 + }, 2552 + "node_modules/@rolldown/binding-linux-x64-musl": { 2553 + "version": "1.0.0-rc.9", 2554 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", 2555 + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", 2556 + "cpu": [ 2557 + "x64" 2558 + ], 2559 + "dev": true, 2560 + "license": "MIT", 2561 + "optional": true, 2562 + "os": [ 2563 + "linux" 2564 + ], 2565 + "engines": { 2566 + "node": "^20.19.0 || >=22.12.0" 2567 + } 2568 + }, 2569 + "node_modules/@rolldown/binding-openharmony-arm64": { 2570 + "version": "1.0.0-rc.9", 2571 + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", 2572 + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", 2573 + "cpu": [ 2574 + "arm64" 2575 + ], 2576 + "dev": true, 2577 + "license": "MIT", 2578 + "optional": true, 2579 + "os": [ 2580 + "openharmony" 2581 + ], 2582 + "engines": { 2583 + "node": "^20.19.0 || >=22.12.0" 2584 + } 2585 + }, 2586 + "node_modules/@rolldown/binding-wasm32-wasi": { 2587 + "version": "1.0.0-rc.9", 2588 + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", 2589 + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", 2590 + "cpu": [ 2591 + "wasm32" 2592 + ], 2593 + "dev": true, 2594 + "license": "MIT", 2595 + "optional": true, 2596 + "dependencies": { 2597 + "@napi-rs/wasm-runtime": "^1.1.1" 2598 + }, 2599 + "engines": { 2600 + "node": ">=14.0.0" 2601 + } 2602 + }, 2603 + "node_modules/@rolldown/binding-win32-arm64-msvc": { 2604 + "version": "1.0.0-rc.9", 2605 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", 2606 + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", 2607 + "cpu": [ 2608 + "arm64" 2609 + ], 2610 + "dev": true, 2611 + "license": "MIT", 2612 + "optional": true, 2613 + "os": [ 2614 + "win32" 2615 + ], 2616 + "engines": { 2617 + "node": "^20.19.0 || >=22.12.0" 2618 + } 2619 + }, 2620 + "node_modules/@rolldown/binding-win32-x64-msvc": { 2621 + "version": "1.0.0-rc.9", 2622 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", 2623 + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", 2624 + "cpu": [ 2625 + "x64" 2626 + ], 2627 + "dev": true, 2628 + "license": "MIT", 2629 + "optional": true, 2630 + "os": [ 2631 + "win32" 2632 + ], 2633 + "engines": { 2634 + "node": "^20.19.0 || >=22.12.0" 2635 + } 2636 + }, 2637 + "node_modules/@rolldown/pluginutils": { 2638 + "version": "1.0.0-rc.9", 2639 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", 2640 + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", 2641 + "dev": true, 2642 + "license": "MIT" 2643 + }, 2319 2644 "node_modules/@rollup/pluginutils": { 2320 2645 "version": "5.3.0", 2321 2646 "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", ··· 2757 3082 "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", 2758 3083 "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", 2759 3084 "license": "MIT" 3085 + }, 3086 + "node_modules/@tybys/wasm-util": { 3087 + "version": "0.10.1", 3088 + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", 3089 + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", 3090 + "dev": true, 3091 + "license": "MIT", 3092 + "optional": true, 3093 + "dependencies": { 3094 + "tslib": "^2.4.0" 3095 + } 2760 3096 }, 2761 3097 "node_modules/@types/better-sqlite3": { 2762 3098 "version": "7.6.13", ··· 3342 3678 } 3343 3679 }, 3344 3680 "node_modules/better-sqlite3": { 3345 - "version": "12.6.2", 3346 - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", 3347 - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", 3681 + "version": "12.8.0", 3682 + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", 3683 + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", 3348 3684 "hasInstallScript": true, 3349 3685 "license": "MIT", 3350 3686 "dependencies": { ··· 5052 5388 "node": ">= 8" 5053 5389 } 5054 5390 }, 5391 + "node_modules/lightningcss": { 5392 + "version": "1.32.0", 5393 + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", 5394 + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", 5395 + "devOptional": true, 5396 + "license": "MPL-2.0", 5397 + "dependencies": { 5398 + "detect-libc": "^2.0.3" 5399 + }, 5400 + "engines": { 5401 + "node": ">= 12.0.0" 5402 + }, 5403 + "funding": { 5404 + "type": "opencollective", 5405 + "url": "https://opencollective.com/parcel" 5406 + }, 5407 + "optionalDependencies": { 5408 + "lightningcss-android-arm64": "1.32.0", 5409 + "lightningcss-darwin-arm64": "1.32.0", 5410 + "lightningcss-darwin-x64": "1.32.0", 5411 + "lightningcss-freebsd-x64": "1.32.0", 5412 + "lightningcss-linux-arm-gnueabihf": "1.32.0", 5413 + "lightningcss-linux-arm64-gnu": "1.32.0", 5414 + "lightningcss-linux-arm64-musl": "1.32.0", 5415 + "lightningcss-linux-x64-gnu": "1.32.0", 5416 + "lightningcss-linux-x64-musl": "1.32.0", 5417 + "lightningcss-win32-arm64-msvc": "1.32.0", 5418 + "lightningcss-win32-x64-msvc": "1.32.0" 5419 + } 5420 + }, 5421 + "node_modules/lightningcss-android-arm64": { 5422 + "version": "1.32.0", 5423 + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", 5424 + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", 5425 + "cpu": [ 5426 + "arm64" 5427 + ], 5428 + "license": "MPL-2.0", 5429 + "optional": true, 5430 + "os": [ 5431 + "android" 5432 + ], 5433 + "engines": { 5434 + "node": ">= 12.0.0" 5435 + }, 5436 + "funding": { 5437 + "type": "opencollective", 5438 + "url": "https://opencollective.com/parcel" 5439 + } 5440 + }, 5441 + "node_modules/lightningcss-darwin-arm64": { 5442 + "version": "1.32.0", 5443 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", 5444 + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", 5445 + "cpu": [ 5446 + "arm64" 5447 + ], 5448 + "license": "MPL-2.0", 5449 + "optional": true, 5450 + "os": [ 5451 + "darwin" 5452 + ], 5453 + "engines": { 5454 + "node": ">= 12.0.0" 5455 + }, 5456 + "funding": { 5457 + "type": "opencollective", 5458 + "url": "https://opencollective.com/parcel" 5459 + } 5460 + }, 5461 + "node_modules/lightningcss-darwin-x64": { 5462 + "version": "1.32.0", 5463 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", 5464 + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", 5465 + "cpu": [ 5466 + "x64" 5467 + ], 5468 + "license": "MPL-2.0", 5469 + "optional": true, 5470 + "os": [ 5471 + "darwin" 5472 + ], 5473 + "engines": { 5474 + "node": ">= 12.0.0" 5475 + }, 5476 + "funding": { 5477 + "type": "opencollective", 5478 + "url": "https://opencollective.com/parcel" 5479 + } 5480 + }, 5481 + "node_modules/lightningcss-freebsd-x64": { 5482 + "version": "1.32.0", 5483 + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", 5484 + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", 5485 + "cpu": [ 5486 + "x64" 5487 + ], 5488 + "license": "MPL-2.0", 5489 + "optional": true, 5490 + "os": [ 5491 + "freebsd" 5492 + ], 5493 + "engines": { 5494 + "node": ">= 12.0.0" 5495 + }, 5496 + "funding": { 5497 + "type": "opencollective", 5498 + "url": "https://opencollective.com/parcel" 5499 + } 5500 + }, 5501 + "node_modules/lightningcss-linux-arm-gnueabihf": { 5502 + "version": "1.32.0", 5503 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", 5504 + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", 5505 + "cpu": [ 5506 + "arm" 5507 + ], 5508 + "license": "MPL-2.0", 5509 + "optional": true, 5510 + "os": [ 5511 + "linux" 5512 + ], 5513 + "engines": { 5514 + "node": ">= 12.0.0" 5515 + }, 5516 + "funding": { 5517 + "type": "opencollective", 5518 + "url": "https://opencollective.com/parcel" 5519 + } 5520 + }, 5521 + "node_modules/lightningcss-linux-arm64-gnu": { 5522 + "version": "1.32.0", 5523 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", 5524 + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", 5525 + "cpu": [ 5526 + "arm64" 5527 + ], 5528 + "license": "MPL-2.0", 5529 + "optional": true, 5530 + "os": [ 5531 + "linux" 5532 + ], 5533 + "engines": { 5534 + "node": ">= 12.0.0" 5535 + }, 5536 + "funding": { 5537 + "type": "opencollective", 5538 + "url": "https://opencollective.com/parcel" 5539 + } 5540 + }, 5541 + "node_modules/lightningcss-linux-arm64-musl": { 5542 + "version": "1.32.0", 5543 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", 5544 + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", 5545 + "cpu": [ 5546 + "arm64" 5547 + ], 5548 + "license": "MPL-2.0", 5549 + "optional": true, 5550 + "os": [ 5551 + "linux" 5552 + ], 5553 + "engines": { 5554 + "node": ">= 12.0.0" 5555 + }, 5556 + "funding": { 5557 + "type": "opencollective", 5558 + "url": "https://opencollective.com/parcel" 5559 + } 5560 + }, 5561 + "node_modules/lightningcss-linux-x64-gnu": { 5562 + "version": "1.32.0", 5563 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", 5564 + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", 5565 + "cpu": [ 5566 + "x64" 5567 + ], 5568 + "license": "MPL-2.0", 5569 + "optional": true, 5570 + "os": [ 5571 + "linux" 5572 + ], 5573 + "engines": { 5574 + "node": ">= 12.0.0" 5575 + }, 5576 + "funding": { 5577 + "type": "opencollective", 5578 + "url": "https://opencollective.com/parcel" 5579 + } 5580 + }, 5581 + "node_modules/lightningcss-linux-x64-musl": { 5582 + "version": "1.32.0", 5583 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", 5584 + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", 5585 + "cpu": [ 5586 + "x64" 5587 + ], 5588 + "license": "MPL-2.0", 5589 + "optional": true, 5590 + "os": [ 5591 + "linux" 5592 + ], 5593 + "engines": { 5594 + "node": ">= 12.0.0" 5595 + }, 5596 + "funding": { 5597 + "type": "opencollective", 5598 + "url": "https://opencollective.com/parcel" 5599 + } 5600 + }, 5601 + "node_modules/lightningcss-win32-arm64-msvc": { 5602 + "version": "1.32.0", 5603 + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", 5604 + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", 5605 + "cpu": [ 5606 + "arm64" 5607 + ], 5608 + "license": "MPL-2.0", 5609 + "optional": true, 5610 + "os": [ 5611 + "win32" 5612 + ], 5613 + "engines": { 5614 + "node": ">= 12.0.0" 5615 + }, 5616 + "funding": { 5617 + "type": "opencollective", 5618 + "url": "https://opencollective.com/parcel" 5619 + } 5620 + }, 5621 + "node_modules/lightningcss-win32-x64-msvc": { 5622 + "version": "1.32.0", 5623 + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", 5624 + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", 5625 + "cpu": [ 5626 + "x64" 5627 + ], 5628 + "license": "MPL-2.0", 5629 + "optional": true, 5630 + "os": [ 5631 + "win32" 5632 + ], 5633 + "engines": { 5634 + "node": ">= 12.0.0" 5635 + }, 5636 + "funding": { 5637 + "type": "opencollective", 5638 + "url": "https://opencollective.com/parcel" 5639 + } 5640 + }, 5055 5641 "node_modules/linebreak": { 5056 5642 "version": "1.1.0", 5057 5643 "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", ··· 7224 7810 "url": "https://opencollective.com/unified" 7225 7811 } 7226 7812 }, 7813 + "node_modules/rolldown": { 7814 + "version": "1.0.0-rc.9", 7815 + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", 7816 + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", 7817 + "dev": true, 7818 + "license": "MIT", 7819 + "dependencies": { 7820 + "@oxc-project/types": "=0.115.0", 7821 + "@rolldown/pluginutils": "1.0.0-rc.9" 7822 + }, 7823 + "bin": { 7824 + "rolldown": "bin/cli.mjs" 7825 + }, 7826 + "engines": { 7827 + "node": "^20.19.0 || >=22.12.0" 7828 + }, 7829 + "optionalDependencies": { 7830 + "@rolldown/binding-android-arm64": "1.0.0-rc.9", 7831 + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", 7832 + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", 7833 + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", 7834 + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", 7835 + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", 7836 + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", 7837 + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", 7838 + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", 7839 + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", 7840 + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", 7841 + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", 7842 + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", 7843 + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", 7844 + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" 7845 + } 7846 + }, 7227 7847 "node_modules/rollup": { 7228 7848 "version": "4.59.0", 7229 7849 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", ··· 9019 9639 "@playwright/test": "^1.58.2", 9020 9640 "@types/better-sqlite3": "^7.6.13", 9021 9641 "@types/react": "^19.2.14", 9022 - "vite": "^6" 9642 + "vite": "^8.0.0" 9643 + }, 9644 + "peerDependencies": { 9645 + "vite": "^8.0.0" 9646 + } 9647 + }, 9648 + "packages/hatk/node_modules/vite": { 9649 + "version": "8.0.0", 9650 + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", 9651 + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", 9652 + "dev": true, 9653 + "license": "MIT", 9654 + "dependencies": { 9655 + "@oxc-project/runtime": "0.115.0", 9656 + "lightningcss": "^1.32.0", 9657 + "picomatch": "^4.0.3", 9658 + "postcss": "^8.5.8", 9659 + "rolldown": "1.0.0-rc.9", 9660 + "tinyglobby": "^0.2.15" 9661 + }, 9662 + "bin": { 9663 + "vite": "bin/vite.js" 9664 + }, 9665 + "engines": { 9666 + "node": "^20.19.0 || >=22.12.0" 9667 + }, 9668 + "funding": { 9669 + "url": "https://github.com/vitejs/vite?sponsor=1" 9670 + }, 9671 + "optionalDependencies": { 9672 + "fsevents": "~2.3.3" 9673 + }, 9674 + "peerDependencies": { 9675 + "@types/node": "^20.19.0 || >=22.12.0", 9676 + "@vitejs/devtools": "^0.0.0-alpha.31", 9677 + "esbuild": "^0.27.0", 9678 + "jiti": ">=1.21.0", 9679 + "less": "^4.0.0", 9680 + "sass": "^1.70.0", 9681 + "sass-embedded": "^1.70.0", 9682 + "stylus": ">=0.54.8", 9683 + "sugarss": "^5.0.0", 9684 + "terser": "^5.16.0", 9685 + "tsx": "^4.8.1", 9686 + "yaml": "^2.4.2" 9687 + }, 9688 + "peerDependenciesMeta": { 9689 + "@types/node": { 9690 + "optional": true 9691 + }, 9692 + "@vitejs/devtools": { 9693 + "optional": true 9694 + }, 9695 + "esbuild": { 9696 + "optional": true 9697 + }, 9698 + "jiti": { 9699 + "optional": true 9700 + }, 9701 + "less": { 9702 + "optional": true 9703 + }, 9704 + "sass": { 9705 + "optional": true 9706 + }, 9707 + "sass-embedded": { 9708 + "optional": true 9709 + }, 9710 + "stylus": { 9711 + "optional": true 9712 + }, 9713 + "sugarss": { 9714 + "optional": true 9715 + }, 9716 + "terser": { 9717 + "optional": true 9718 + }, 9719 + "tsx": { 9720 + "optional": true 9721 + }, 9722 + "yaml": { 9723 + "optional": true 9724 + } 9023 9725 } 9024 9726 }, 9025 9727 "packages/oauth-client": {
+3
package.json
··· 26 26 }, 27 27 "engines": { 28 28 "node": ">=25.0.0" 29 + }, 30 + "dependencies": { 31 + "better-sqlite3": "^12.8.0" 29 32 } 30 33 }
+6 -4
packages/hatk/package.json
··· 23 23 "./hooks": "./dist/hooks.js", 24 24 "./setup": "./dist/setup.js", 25 25 "./test": "./dist/test.js", 26 - "./test/browser": "./dist/test-browser.js", 27 26 "./config": "./dist/config.js", 28 - "./vite-plugin": "./dist/vite-plugin.js" 27 + "./vite-plugin": "./dist/vite-plugin.js", 28 + "./renderer": "./dist/renderer.js" 29 29 }, 30 30 "scripts": { 31 31 "build": "tsc -p tsconfig.build.json", ··· 41 41 "vitest": "^4", 42 42 "yaml": "^2.7.0" 43 43 }, 44 + "peerDependencies": { 45 + "vite": "^8.0.0" 46 + }, 44 47 "devDependencies": { 45 - "@playwright/test": "^1.58.2", 46 48 "@types/better-sqlite3": "^7.6.13", 47 49 "@types/react": "^19.2.14", 48 - "vite": "^6" 50 + "vite": "^8.0.0" 49 51 } 50 52 }
+102
packages/hatk/src/adapter.ts
··· 1 + import { type IncomingMessage, type ServerResponse, createServer } from 'node:http' 2 + 3 + /** 4 + * Convert a Node.js IncomingMessage to a Web Standard Request. 5 + */ 6 + export function toRequest(req: IncomingMessage, base: string): Request { 7 + const url = new URL(req.url!, base) 8 + const headers = new Headers() 9 + for (const [key, value] of Object.entries(req.headers)) { 10 + if (value) { 11 + if (Array.isArray(value)) { 12 + for (const v of value) headers.append(key, v) 13 + } else { 14 + headers.set(key, value) 15 + } 16 + } 17 + } 18 + 19 + const init: RequestInit & { duplex?: string } = { 20 + method: req.method, 21 + headers, 22 + } 23 + 24 + // GET and HEAD requests cannot have a body 25 + if (req.method !== 'GET' && req.method !== 'HEAD') { 26 + // @ts-expect-error — Node.js streams are valid body sources 27 + init.body = req 28 + init.duplex = 'half' 29 + } 30 + 31 + return new Request(url.href, init as RequestInit) 32 + } 33 + 34 + /** 35 + * Pipe a Web Standard Response back to a Node.js ServerResponse. 36 + */ 37 + export async function sendResponse(res: ServerResponse, response: Response): Promise<void> { 38 + res.writeHead(response.status, Object.fromEntries(response.headers.entries())) 39 + 40 + if (!response.body) { 41 + res.end() 42 + return 43 + } 44 + 45 + const reader = response.body.getReader() 46 + try { 47 + while (true) { 48 + const { done, value } = await reader.read() 49 + if (done) break 50 + res.write(value) 51 + } 52 + } finally { 53 + reader.releaseLock() 54 + res.end() 55 + } 56 + } 57 + 58 + /** Routes handled by hatk — everything else can fall through to a framework handler. */ 59 + const HATK_ROUTES = ['/xrpc/', '/oauth/', '/.well-known/', '/og/', '/admin', '/repos', '/info/', '/_health', '/robots.txt', '/auth/logout'] 60 + 61 + function isHatkRoute(pathname: string): boolean { 62 + return HATK_ROUTES.some(r => pathname.startsWith(r) || pathname === r) 63 + } 64 + 65 + /** 66 + * Create a Node.js HTTP server from a Web Standard fetch handler. 67 + * If a fallback Node middleware is provided, non-hatk routes are sent to it 68 + * (e.g. SvelteKit's handler from build/handler.js). 69 + */ 70 + export function serve( 71 + handler: (request: Request) => Promise<Response>, 72 + port: number, 73 + base?: string, 74 + fallback?: (req: IncomingMessage, res: ServerResponse, next: () => void) => void, 75 + ) { 76 + const origin = base || `http://localhost:${port}` 77 + const server = createServer(async (req, res) => { 78 + try { 79 + const url = new URL(req.url!, origin) 80 + 81 + // If we have a fallback (e.g. SvelteKit) and this isn't a hatk route, skip hatk 82 + if (fallback && !isHatkRoute(url.pathname)) { 83 + fallback(req, res, () => { 84 + res.writeHead(404) 85 + res.end('Not found') 86 + }) 87 + return 88 + } 89 + 90 + const request = toRequest(req, origin) 91 + const response = await handler(request) 92 + await sendResponse(res, response) 93 + } catch (err: any) { 94 + if (!res.headersSent) { 95 + res.writeHead(500, { 'Content-Type': 'application/json' }) 96 + } 97 + res.end(JSON.stringify({ error: err.message })) 98 + } 99 + }) 100 + server.listen(port) 101 + return server 102 + }
+147 -2
packages/hatk/src/cli.ts
··· 1424 1424 } 1425 1425 entries.sort((a, b) => a.nsid.localeCompare(b.nsid)) 1426 1426 1427 + // Collect procedure nsids and blob-input nsids for client callXrpc 1428 + const procedureNsids: string[] = [] 1429 + const blobInputNsids: string[] = [] 1430 + for (const { nsid, defType } of entries) { 1431 + if (defType === 'procedure') { 1432 + const lex = lexicons.get(nsid) 1433 + const inputEncoding = lex?.defs?.main?.input?.encoding 1434 + if (inputEncoding === '*/*') { 1435 + blobInputNsids.push(nsid) 1436 + } else { 1437 + procedureNsids.push(nsid) 1438 + } 1439 + } 1440 + } 1441 + 1427 1442 if (entries.length === 0) { 1428 1443 console.error('No lexicons found') 1429 1444 process.exit(1) ··· 1474 1489 let out = '// Auto-generated from lexicons. Do not edit.\n' 1475 1490 out += `import type { ${[...usedWrappers].sort().join(', ')}, LexServerParams, Checked, Prettify, StrictArg } from '@hatk/hatk/lex-types'\n` 1476 1491 out += `import type { XrpcContext } from '@hatk/hatk/xrpc'\n` 1492 + out += `import { callXrpc as _callXrpc } from '@hatk/hatk/xrpc'\n` 1477 1493 out += `import { defineFeed as _defineFeed, type FeedResult, type FeedContext, type HydrateContext } from '@hatk/hatk/feeds'\n` 1478 1494 out += `import { seed as _seed, type SeedOpts } from '@hatk/hatk/seed'\n` 1479 1495 ··· 1648 1664 out += `export { defineHook } from '@hatk/hatk/hooks'\n` 1649 1665 out += `export { defineLabels } from '@hatk/hatk/labels'\n` 1650 1666 out += `export { defineOG } from '@hatk/hatk/opengraph'\n` 1667 + out += `export { defineRenderer } from '@hatk/hatk/renderer'\n` 1651 1668 out += `export type Ctx<K extends keyof XrpcSchema & keyof Registry> = XrpcContext<\n` 1652 1669 out += ` LexServerParams<Registry[K], Registry>,\n` 1653 1670 out += ` RecordRegistry,\n` ··· 1668 1685 out += ` handler: (ctx: Ctx<K> & { ok: <T extends OutputOf<K>>(value: StrictArg<T, OutputOf<K>>) => Checked<OutputOf<K>> }) => Promise<Checked<OutputOf<K>>>,\n` 1669 1686 out += `) {\n` 1670 1687 out += ` return { __type: 'procedure' as const, nsid, handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n` 1688 + out += `}\n\n` 1689 + out += `// ─── Server-side XRPC Caller ────────────────────────────────────────\n\n` 1690 + out += `type ExtractParams<T> = T extends { params: infer P } ? P : Record<string, unknown>\n` 1691 + out += `export async function callXrpc<K extends keyof XrpcSchema & string>(\n` 1692 + out += ` nsid: K,\n` 1693 + out += ` params?: ExtractParams<XrpcSchema[K]>,\n` 1694 + out += `): Promise<OutputOf<K>> {\n` 1695 + out += ` return _callXrpc(nsid, params as any) as Promise<OutputOf<K>>\n` 1671 1696 out += `}\n\n` 1672 1697 out += `// ─── Feed & Seed Helpers ────────────────────────────────────────────\n\n` 1673 1698 out += `type FeedGenerate = (ctx: FeedContext & { ok: (value: FeedResult) => Checked<FeedResult> }) => Promise<Checked<FeedResult>>\n` ··· 1704 1729 } 1705 1730 1706 1731 writeFileSync(outPath, out) 1732 + 1733 + // Generate client-safe version (types + callXrpc only, no server module re-exports) 1734 + // Types use `export type` from main file (erased at compile time, no runtime import). 1735 + // callXrpc imports from @hatk/hatk/xrpc directly to avoid pulling in server deps. 1736 + let clientOut = '// Auto-generated client-safe subset. Do not edit.\n' 1737 + clientOut += `// Import this in app components instead of hatk.generated.ts\n` 1738 + clientOut += `// to avoid pulling in server-only dependencies.\n` 1739 + clientOut += `export type { XrpcSchema } from './hatk.generated.ts'\n` 1740 + clientOut += `import type { XrpcSchema } from './hatk.generated.ts'\n` 1741 + 1742 + // Re-export all types 1743 + const typeExports: string[] = [] 1744 + for (const { nsid, defType } of entries) { 1745 + if (!defType) continue 1746 + if (nsid === 'dev.hatk.createRecord' || nsid === 'dev.hatk.deleteRecord' || nsid === 'dev.hatk.putRecord') continue 1747 + typeExports.push(capitalize(varNames.get(nsid)!)) 1748 + } 1749 + if (recordEntries.length > 0) { 1750 + typeExports.push('RecordRegistry', 'CreateRecord', 'DeleteRecord', 'PutRecord') 1751 + } 1752 + // Named defs (views, objects) — collect from emittedDefNames minus main types 1753 + const mainTypeNames = new Set(entries.filter(e => e.defType).map(e => capitalize(varNames.get(e.nsid)!))) 1754 + for (const name of emittedDefNames) { 1755 + if (!mainTypeNames.has(name) && !typeExports.includes(name)) { 1756 + typeExports.push(name) 1757 + } 1758 + } 1759 + if (typeExports.length > 0) { 1760 + clientOut += `export type { ${typeExports.join(', ')} } from './hatk.generated.ts'\n` 1761 + } 1762 + 1763 + // Typed callXrpc — environment-aware: 1764 + // SSR: uses globalThis.__hatk_callXrpc bridge (direct handler invocation) 1765 + // Client: fetches via HTTP (GET for queries, POST for procedures, raw POST for blobs) 1766 + if (procedureNsids.length > 0) { 1767 + clientOut += `\nconst _procedures = new Set([${procedureNsids.map(n => `'${n}'`).join(', ')}])\n` 1768 + } 1769 + if (blobInputNsids.length > 0) { 1770 + clientOut += `const _blobInputs = new Set([${blobInputNsids.map(n => `'${n}'`).join(', ')}])\n` 1771 + } 1772 + 1773 + clientOut += `\ntype CallArg<K extends keyof XrpcSchema> =\n` 1774 + clientOut += ` XrpcSchema[K] extends { input: infer I } ? I :\n` 1775 + clientOut += ` XrpcSchema[K] extends { params: infer P } ? P :\n` 1776 + clientOut += ` Record<string, unknown>\n` 1777 + clientOut += `type OutputOf<K extends keyof XrpcSchema> = XrpcSchema[K]['output']\n\n` 1778 + clientOut += `export async function callXrpc<K extends keyof XrpcSchema & string>(\n` 1779 + clientOut += ` nsid: K,\n` 1780 + clientOut += ` arg?: CallArg<K>,\n` 1781 + clientOut += `): Promise<OutputOf<K>> {\n` 1782 + // Server-side bridge 1783 + clientOut += ` if (typeof window === 'undefined') {\n` 1784 + clientOut += ` const bridge = (globalThis as any).__hatk_callXrpc\n` 1785 + clientOut += ` if (!bridge) throw new Error('callXrpc: server bridge not available — is hatk initialized?')\n` 1786 + if (procedureNsids.length > 0 || blobInputNsids.length > 0) { 1787 + const checks = [] 1788 + if (procedureNsids.length > 0) checks.push('_procedures.has(nsid)') 1789 + if (blobInputNsids.length > 0) checks.push('_blobInputs.has(nsid)') 1790 + clientOut += ` if (${checks.join(' || ')}) return bridge(nsid, {}, arg) as Promise<OutputOf<K>>\n` 1791 + } 1792 + clientOut += ` return bridge(nsid, arg) as Promise<OutputOf<K>>\n` 1793 + clientOut += ` }\n` 1794 + // Client-side fetch 1795 + clientOut += ` const url = new URL(\`/xrpc/\${nsid}\`, window.location.origin)\n` 1796 + if (blobInputNsids.length > 0) { 1797 + clientOut += ` if (_blobInputs.has(nsid)) {\n` 1798 + clientOut += ` const blob = arg as Blob | ArrayBuffer\n` 1799 + clientOut += ` const ct = blob instanceof Blob ? blob.type : 'application/octet-stream'\n` 1800 + clientOut += ` const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': ct }, body: blob })\n` 1801 + clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n` 1802 + clientOut += ` return res.json() as Promise<OutputOf<K>>\n` 1803 + clientOut += ` }\n` 1804 + } 1805 + if (procedureNsids.length > 0) { 1806 + clientOut += ` if (_procedures.has(nsid)) {\n` 1807 + clientOut += ` const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(arg) })\n` 1808 + clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n` 1809 + clientOut += ` return res.json() as Promise<OutputOf<K>>\n` 1810 + clientOut += ` }\n` 1811 + } 1812 + clientOut += ` for (const [k, v] of Object.entries(arg || {})) {\n` 1813 + clientOut += ` if (v != null) url.searchParams.set(k, String(v))\n` 1814 + clientOut += ` }\n` 1815 + clientOut += ` const res = await fetch(url)\n` 1816 + clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n` 1817 + clientOut += ` return res.json() as Promise<OutputOf<K>>\n` 1818 + clientOut += `}\n` 1819 + 1820 + // getViewer — async, resolves from cookies on server via getRequestEvent() 1821 + clientOut += `\nexport async function getViewer(): Promise<{ did: string } | null> {\n` 1822 + clientOut += ` if (typeof window === 'undefined') {\n` 1823 + clientOut += ` try {\n` 1824 + clientOut += ` const parse = (globalThis as any).__hatk_parseSessionCookie\n` 1825 + clientOut += ` if (parse) {\n` 1826 + clientOut += ` const { getRequestEvent } = await import('$app/server')\n` 1827 + clientOut += ` const event = getRequestEvent()\n` 1828 + clientOut += ` const cookieValue = event.cookies.get('__hatk_session')\n` 1829 + clientOut += ` if (cookieValue) {\n` 1830 + clientOut += ` const request = new Request('http://localhost', {\n` 1831 + clientOut += ` headers: { cookie: \`__hatk_session=\${cookieValue}\` },\n` 1832 + clientOut += ` })\n` 1833 + clientOut += ` return parse(request)\n` 1834 + clientOut += ` }\n` 1835 + clientOut += ` }\n` 1836 + clientOut += ` } catch {}\n` 1837 + clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n` 1838 + clientOut += ` }\n` 1839 + clientOut += ` try {\n` 1840 + clientOut += ` const mod = (globalThis as any).__hatk_auth\n` 1841 + clientOut += ` if (mod?.viewerDid) {\n` 1842 + clientOut += ` const did = mod.viewerDid()\n` 1843 + clientOut += ` if (did) return { did }\n` 1844 + clientOut += ` }\n` 1845 + clientOut += ` } catch {}\n` 1846 + clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n` 1847 + clientOut += `}\n` 1848 + 1849 + writeFileSync('./hatk.generated.client.ts', clientOut) 1850 + 1707 1851 console.log( 1708 1852 `Generated ${outPath} with ${entries.length} types: ${entries.map((e) => capitalize(varNames.get(e.nsid)!)).join(', ')}`, 1709 1853 ) 1854 + console.log(`Generated ./hatk.generated.client.ts (client-safe subset)`) 1710 1855 } else if (lexiconTemplates[type]) { 1711 1856 const nsid = args[2] 1712 1857 if (!nsid || !nsid.includes('.')) { ··· 1798 1943 await ensurePds() 1799 1944 runSeed() 1800 1945 1801 - if (existsSync(resolve('svelte.config.js')) && existsSync(resolve('src/app.html'))) { 1802 - // SvelteKit project — vite dev starts the hatk server via the plugin 1946 + if (existsSync(resolve('vite.config.ts')) || existsSync(resolve('vite.config.js'))) { 1947 + // Vite project — vite dev starts the hatk server via the plugin 1803 1948 await spawnForward('npx', ['vite', 'dev']) 1804 1949 } else { 1805 1950 // No frontend — just run the hatk server directly
+2 -1
packages/hatk/src/config.ts
··· 26 26 issuer: string 27 27 scopes: string[] 28 28 clients: OAuthClientConfig[] 29 + cookieName?: string 29 30 } 30 31 31 32 export interface BackfillConfig { ··· 80 81 const configDir = dirname(resolved) 81 82 let mod: any 82 83 try { 83 - mod = await import(resolved) 84 + mod = await import(/* @vite-ignore */ resolved) 84 85 } catch (err: any) { 85 86 console.error(`Failed to load config file: ${resolved}`) 86 87 console.error(err.message || err)
+5 -1
packages/hatk/src/database/db.ts
··· 466 466 `SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_fts_%' AND sql IS NOT NULL ORDER BY name`, 467 467 ) 468 468 } 469 - // Normalize indentation 469 + // Normalize indentation and formatting 470 470 return rows 471 471 .map((r: any) => { 472 472 let sql = (r.sql as string).trim() 473 + // Remove quotes around column names (SQLite adds them for some columns) 474 + sql = sql.replace(/\n\s*"(\w+)"/g, '\n$1') 475 + // Ensure closing paren is on its own line 476 + sql = sql.replace(/([^(\s])\)$/, '$1\n)') 473 477 // Split into lines and re-indent consistently 474 478 const lines = sql.split('\n').map((l) => l.trim()) 475 479 sql = lines
+127
packages/hatk/src/dev-entry.ts
··· 1 + /** 2 + * Dev mode entry point — loaded through Vite's module runner. 3 + * Boots hatk infrastructure and exports the fetch handler. 4 + */ 5 + import { loadConfig } from './config.ts' 6 + import { loadLexicons, storeLexicons, discoverCollections, buildSchemas } from './database/schema.ts' 7 + import { discoverViews } from './views.ts' 8 + import { initDatabase, migrateSchema, getSchemaDump } from './database/db.ts' 9 + import { createAdapter } from './database/adapter-factory.ts' 10 + import { getDialect } from './database/dialect.ts' 11 + import { setSearchPort } from './database/fts.ts' 12 + import { configureRelay } from './xrpc.ts' 13 + import { initOAuth } from './oauth/server.ts' 14 + import { initServer } from './server-init.ts' 15 + import { createHandler, registerCoreHandlers } from './server.ts' 16 + import { startIndexer } from './indexer.ts' 17 + import { getCursor } from './database/db.ts' 18 + import { runBackfill } from './backfill.ts' 19 + import { rebuildAllIndexes } from './database/fts.ts' 20 + import { relayHttpUrl } from './config.ts' 21 + import { validateLexicons } from '@bigmoves/lexicon' 22 + import { log } from './logger.ts' 23 + import { mkdirSync } from 'node:fs' 24 + import { dirname, resolve } from 'node:path' 25 + 26 + process.env.DEV_MODE = '1' 27 + 28 + // Boot sequence (mirrors main.ts but exports handler instead of starting server) 29 + const configPath = 'hatk.config.ts' 30 + const configDir = dirname(resolve(configPath)) 31 + 32 + const config = await loadConfig(configPath) 33 + configureRelay(config.relay) 34 + 35 + const lexicons = loadLexicons(resolve(configDir, 'lexicons')) 36 + const lexiconErrors = validateLexicons([...lexicons.values()]) 37 + if (lexiconErrors) { 38 + for (const [nsid, errors] of Object.entries(lexiconErrors)) { 39 + for (const err of errors) console.error(`Invalid lexicon ${nsid}: ${err}`) 40 + } 41 + throw new Error('Invalid lexicons') 42 + } 43 + storeLexicons(lexicons) 44 + 45 + const collections = config.collections.length > 0 ? config.collections : discoverCollections(lexicons) 46 + discoverViews() 47 + 48 + const engineDialect = getDialect(config.databaseEngine) 49 + const { schemas, ddlStatements } = buildSchemas(lexicons, collections, engineDialect) 50 + 51 + if (config.database !== ':memory:') { 52 + mkdirSync(dirname(config.database), { recursive: true }) 53 + } 54 + const { adapter, searchPort } = await createAdapter(config.databaseEngine) 55 + setSearchPort(searchPort) 56 + await initDatabase(adapter, config.database, schemas, ddlStatements) 57 + await migrateSchema(schemas) 58 + 59 + // Write db/schema.sql 60 + try { 61 + const { mkdirSync, writeFileSync } = await import('node:fs') 62 + const schemaDir = resolve(configDir, 'db') 63 + mkdirSync(schemaDir, { recursive: true }) 64 + const schemaDump = await getSchemaDump() 65 + writeFileSync( 66 + resolve(schemaDir, 'schema.sql'), 67 + `-- This file is auto-generated by hatk on startup. Do not edit.\n-- Database engine: ${config.databaseEngine}\n\n${schemaDump}\n`, 68 + ) 69 + log(`[hatk] Schema written to db/schema.sql`) 70 + } catch {} 71 + 72 + // Initialize handlers from server/ directory 73 + await initServer(resolve(configDir, 'server')) 74 + 75 + // Register built-in dev.hatk.* handlers so callXrpc() can find them 76 + registerCoreHandlers(collections, config.oauth) 77 + 78 + if (config.oauth) { 79 + await initOAuth(config.oauth, config.plc, config.relay) 80 + } 81 + 82 + // Start indexer 83 + const collectionSet = new Set(collections) 84 + const cursor = await getCursor('relay') 85 + startIndexer({ 86 + relayUrl: config.relay, 87 + collections: collectionSet, 88 + signalCollections: config.backfill.signalCollections ? new Set(config.backfill.signalCollections) : undefined, 89 + pinnedRepos: config.backfill.repos ? new Set(config.backfill.repos) : undefined, 90 + cursor, 91 + fetchTimeout: config.backfill.fetchTimeout, 92 + maxRetries: config.backfill.maxRetries, 93 + parallelism: config.backfill.parallelism, 94 + ftsRebuildInterval: config.ftsRebuildInterval, 95 + }) 96 + 97 + // Run backfill in background (no restart in dev mode) 98 + runBackfill({ 99 + pdsUrl: relayHttpUrl(config.relay), 100 + plcUrl: config.plc, 101 + collections: collectionSet, 102 + config: config.backfill, 103 + }).then(() => rebuildAllIndexes(Array.from(collectionSet))) 104 + .catch((err) => console.error('[backfill]', err.message)) 105 + 106 + // Export the handler for Vite middleware 107 + export const handler = createHandler({ 108 + collections: Array.from(collectionSet), 109 + publicDir: null, // Vite serves static assets in dev 110 + oauth: config.oauth, 111 + admins: config.admins, 112 + }) 113 + 114 + /** Re-scan server/ directory to pick up changed handlers in dev mode. */ 115 + export async function reloadServer() { 116 + await initServer(resolve(configDir, 'server')) 117 + } 118 + 119 + export { renderPage } from './renderer.ts' 120 + export { getRenderer } from './renderer.ts' 121 + export { callXrpc } from './xrpc.ts' 122 + export { parseSessionCookie } from './oauth/session.ts' 123 + 124 + log(`[hatk] Dev server ready`) 125 + log(` Relay: ${config.relay}`) 126 + log(` Database: ${config.database}`) 127 + log(` Collections: ${collections.join(', ')}`)
+1 -1
packages/hatk/src/feeds.ts
··· 194 194 for (const file of files) { 195 195 const name = file.replace(/\.(ts|js)$/, '') 196 196 const scriptPath = resolve(feedsDir, file) 197 - const mod = await import(scriptPath) 197 + const mod = await import(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`) 198 198 const generator = mod.default 199 199 200 200 const handler: FeedHandler = {
+1 -1
packages/hatk/src/hooks.ts
··· 50 50 const jsPath = resolve(hooksDir, 'on-login.js') 51 51 const path = existsSync(tsPath) ? tsPath : existsSync(jsPath) ? jsPath : null 52 52 if (!path) return 53 - const mod = await import(path) 53 + const mod = await import(/* @vite-ignore */ `${path}?t=${Date.now()}`) 54 54 onLoginHook = mod.default 55 55 log('[hooks] on-login hook loaded') 56 56 }
+1 -1
packages/hatk/src/labels.ts
··· 88 88 for (const file of files) { 89 89 const name = file.replace(/\.(ts|js)$/, '') 90 90 const scriptPath = resolve(labelsDir, file) 91 - const mod = await import(scriptPath) 91 + const mod = await import(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`) 92 92 const handler = mod.default 93 93 94 94 if (handler.definition) {
+23 -2
packages/hatk/src/main.ts
··· 15 15 import { initLabels, getLabelDefinitions } from './labels.ts' 16 16 import { startIndexer } from './indexer.ts' 17 17 import { rebuildAllIndexes } from './database/fts.ts' 18 - import { startServer } from './server.ts' 18 + import { createHandler, registerCoreHandlers } from './server.ts' 19 + import { serve } from './adapter.ts' 19 20 import { validateLexicons } from '@bigmoves/lexicon' 20 21 import { relayHttpUrl } from './config.ts' 21 22 import { runBackfill } from './backfill.ts' ··· 112 113 log(`[main] Labels initialized: ${getLabelDefinitions().length} definitions`) 113 114 } 114 115 116 + // Register built-in dev.hatk.* handlers so callXrpc() can find them 117 + registerCoreHandlers(collections, config.oauth) 118 + 115 119 // Write db/schema.sql (after setup, so setup-created tables are included) 116 120 try { 117 121 const schemaDir = resolve(configDir, 'db') ··· 174 178 }) 175 179 } 176 180 177 - startServer(config.port, collections, config.publicDir, config.oauth, config.admins, undefined, runBackfillAndRestart) 181 + const handler = createHandler({ 182 + collections, 183 + publicDir: config.publicDir, 184 + oauth: config.oauth, 185 + admins: config.admins, 186 + onResync: runBackfillAndRestart, 187 + }) 188 + 189 + // Detect SvelteKit build output and use it as fallback handler 190 + let fallback: any = undefined 191 + const sveltekitHandler = resolve(configDir, 'build', 'handler.js') 192 + if (existsSync(sveltekitHandler)) { 193 + const sk = await import(/* @vite-ignore */ sveltekitHandler) 194 + fallback = sk.handler 195 + log(`[main] SvelteKit handler loaded from build/handler.js`) 196 + } 197 + 198 + serve(handler, config.port, undefined, fallback) 178 199 179 200 log(`\nhatk running:`) 180 201 log(` Relay: ${config.relay}`)
+107 -2
packages/hatk/src/oauth/server.ts
··· 14 14 base64UrlEncode, 15 15 } from './crypto.ts' 16 16 import { parseDpopProof, createDpopProof } from './dpop.ts' 17 + import { initSession } from './session.ts' 17 18 import { resolveClient, validateRedirectUri, isLoopbackClient } from './client.ts' 18 19 import { discoverAuthServer, resolveHandle } from './discovery.ts' 19 20 import { ··· 90 91 } 91 92 serverPrivateKey = await importPrivateKey(serverPrivateJwk) 92 93 serverJkt = await computeJwkThumbprint(serverPublicJwk) 94 + 95 + // Initialize SSR session cookie signing 96 + initSession(serverPrivateJwk, _config.cookieName) 93 97 94 98 // Periodic cleanup of expired OAuth data 95 99 setInterval(() => cleanupExpiredOAuth().catch(() => {}), 60_000) ··· 319 323 return `${request.pds_auth_server}/oauth/authorize?${params}` 320 324 } 321 325 326 + // --- Server-initiated login (no DPoP required from browser) --- 327 + 328 + export async function serverLogin( 329 + config: OAuthConfig, 330 + handle: string, 331 + ): Promise<string> { 332 + // Resolve handle to DID 333 + let did = handle 334 + if (!did.startsWith('did:')) { 335 + did = await resolveHandle(handle, _relayUrl) 336 + } 337 + 338 + // Discover PDS auth server 339 + const discovery = await discoverAuthServer(did, _plcUrl) 340 + const pdsAuthServer = discovery.authServerEndpoint 341 + 342 + // Create PKCE for PAR to PDS 343 + const pdsCodeVerifier = randomToken() 344 + const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier)) 345 + const pdsState = randomToken() 346 + 347 + // PAR to the PDS 348 + const parEndpoint = 349 + discovery.authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par` 350 + const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint) 351 + 352 + const scope = config.scopes?.join(' ') || 'atproto transition:generic' 353 + const pdsParBody = new URLSearchParams({ 354 + client_id: pdsClientId(config.issuer, config), 355 + redirect_uri: pdsRedirectUri(config.issuer), 356 + response_type: 'code', 357 + code_challenge: pdsCodeChallenge, 358 + code_challenge_method: 'S256', 359 + scope, 360 + login_hint: handle, 361 + state: pdsState, 362 + }) 363 + 364 + let pdsRequestUri: string | undefined 365 + 366 + const pdsParRes = await fetch(parEndpoint, { 367 + method: 'POST', 368 + headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof }, 369 + body: pdsParBody.toString(), 370 + }) 371 + 372 + if (!pdsParRes.ok) { 373 + const errBody = await pdsParRes.json().catch(() => ({})) 374 + if (errBody.error === 'use_dpop_nonce') { 375 + const nonce = pdsParRes.headers.get('DPoP-Nonce') 376 + if (nonce) { 377 + const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint, undefined, nonce) 378 + const retryRes = await fetch(parEndpoint, { 379 + method: 'POST', 380 + headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof }, 381 + body: pdsParBody.toString(), 382 + }) 383 + if (!retryRes.ok) { 384 + const retryErr = await retryRes.json().catch(() => ({})) 385 + throw new Error(`PDS PAR failed: ${retryRes.status} ${retryErr.error_description || retryErr.error || ''}`) 386 + } 387 + const retryData = await retryRes.json() 388 + pdsRequestUri = retryData.request_uri 389 + } 390 + } else { 391 + throw new Error(`PDS PAR failed: ${pdsParRes.status} ${errBody.error_description || errBody.error || ''}`) 392 + } 393 + } else { 394 + const pdsParData = await pdsParRes.json() 395 + pdsRequestUri = pdsParData.request_uri 396 + } 397 + 398 + // Store the request so the callback can find it 399 + const requestUri = `urn:ietf:params:oauth:request_uri:${randomToken()}` 400 + const expiresAt = Math.floor(Date.now() / 1000) + 600 401 + 402 + await storeOAuthRequest(requestUri, { 403 + clientId: pdsClientId(config.issuer, config), 404 + redirectUri: '/', 405 + scope, 406 + state: pdsState, 407 + codeChallenge: '', 408 + codeChallengeMethod: 'S256', 409 + dpopJkt: serverJkt, 410 + pdsRequestUri, 411 + pdsAuthServer, 412 + pdsCodeVerifier, 413 + pdsState, 414 + did, 415 + loginHint: handle, 416 + expiresAt, 417 + }) 418 + 419 + // Build redirect URL to PDS 420 + const params = new URLSearchParams({ 421 + request_uri: pdsRequestUri!, 422 + client_id: pdsClientId(config.issuer, config), 423 + }) 424 + return `${pdsAuthServer}/oauth/authorize?${params}` 425 + } 426 + 322 427 // --- OAuth Callback (PDS redirects here) --- 323 428 324 429 export async function handleCallback( ··· 326 431 code: string, 327 432 state: string | null, 328 433 iss: string | null, 329 - ): Promise<{ requestUri: string; clientRedirectUri: string; clientState: string | null }> { 434 + ): Promise<{ requestUri: string; clientRedirectUri: string; clientState: string | null; did: string }> { 330 435 // Find the matching OAuth request by pds_state (unique per PAR) 331 436 const { querySQL } = await import('../database/db.ts') 332 437 let request: any = null ··· 446 551 if (request.state) params.set('state', request.state) 447 552 const clientRedirectUri = `${request.redirect_uri}?${params}` 448 553 449 - return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state } 554 + return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did } 450 555 } 451 556 452 557 // --- Token Endpoint ---
+69
packages/hatk/src/oauth/session.ts
··· 1 + // SSR session cookie — signed HttpOnly cookie for server-side viewer resolution. 2 + // Separate from OAuth protocol flows but uses the same server keypair. 3 + 4 + import { base64UrlEncode, base64UrlDecode } from './crypto.ts' 5 + 6 + let _privateJwk: JsonWebKey 7 + let _cookieName = '__hatk_session' 8 + const MAX_AGE = 30 * 24 * 60 * 60 // 30 days in seconds 9 + 10 + export function initSession(privateJwk: JsonWebKey, cookieName?: string): void { 11 + _privateJwk = privateJwk 12 + if (cookieName) _cookieName = cookieName 13 + } 14 + 15 + async function hmacKey(usage: 'sign' | 'verify'): Promise<CryptoKey> { 16 + return crypto.subtle.importKey( 17 + 'raw', 18 + new TextEncoder().encode(JSON.stringify(_privateJwk)), 19 + { name: 'HMAC', hash: 'SHA-256' }, 20 + false, 21 + [usage], 22 + ) 23 + } 24 + 25 + export async function createSessionCookie(did: string): Promise<string> { 26 + const timestamp = Math.floor(Date.now() / 1000) 27 + const payload = `${did}.${timestamp}` 28 + const key = await hmacKey('sign') 29 + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)) 30 + return `${payload}.${base64UrlEncode(new Uint8Array(sig))}` 31 + } 32 + 33 + export function sessionCookieHeader(value: string, secure: boolean): string { 34 + const parts = [ 35 + `${_cookieName}=${value}`, 36 + 'HttpOnly', 37 + 'SameSite=Lax', 38 + 'Path=/', 39 + `Max-Age=${MAX_AGE}`, 40 + ] 41 + if (secure) parts.push('Secure') 42 + return parts.join('; ') 43 + } 44 + 45 + export function clearSessionCookieHeader(): string { 46 + return `${_cookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0` 47 + } 48 + 49 + export async function parseSessionCookie(request: Request): Promise<{ did: string } | null> { 50 + const cookieHeader = request.headers.get('cookie') 51 + if (!cookieHeader) return null 52 + const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${_cookieName}=`)) 53 + if (!match) return null 54 + const value = match.slice(_cookieName.length + 1) 55 + const parts = value.split('.') 56 + // Format: did:plc:xxx.timestamp.signature — DID contains dots so take last 2 parts 57 + if (parts.length < 3) return null 58 + const signature = parts.pop()! 59 + const timestamp = parts.pop()! 60 + const did = parts.join('.') 61 + const ts = Number(timestamp) 62 + if (isNaN(ts) || (Date.now() / 1000 - ts) > MAX_AGE) return null 63 + const payload = `${did}.${timestamp}` 64 + const key = await hmacKey('verify') 65 + const sigBytes = base64UrlDecode(signature) as Uint8Array<ArrayBuffer> 66 + const valid = await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload)) 67 + if (!valid) return null 68 + return { did } 69 + }
+17 -5
packages/hatk/src/opengraph.ts
··· 1 1 import { resolve } from 'node:path' 2 2 import { readFileSync, readdirSync } from 'node:fs' 3 3 import { log } from './logger.ts' 4 - import satori from 'satori' 5 - import { Resvg } from '@resvg/resvg-js' 4 + // Lazy-imported to avoid CJS require() issues in Vite's module runner 5 + let _satori: typeof import('satori').default | null = null 6 + let _Resvg: typeof import('@resvg/resvg-js').Resvg | null = null 7 + 8 + async function getSatori() { 9 + if (!_satori) _satori = (await import('satori')).default 10 + return _satori 11 + } 12 + 13 + async function getResvg() { 14 + if (!_Resvg) _Resvg = (await import('@resvg/resvg-js')).Resvg 15 + return _Resvg 16 + } 6 17 import { 7 18 querySQL, 8 19 runSQL, ··· 107 118 for (const file of files) { 108 119 const name = file.replace(/\.(ts|js)$/, '') 109 120 const scriptPath = resolve(ogDir, file) 110 - const mod = await import(scriptPath) 121 + const mod = await import(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`) 111 122 const handler = mod.default 112 123 113 124 if (!handler.path) { ··· 172 183 ...result.options, 173 184 fonts: [...(defaultFont ? [defaultFont] : []), ...(result.options?.fonts || [])], 174 185 } 175 - const svg = await satori(element, options) 186 + const svg = await (await getSatori())(element, options) 176 187 return { svg, meta: result.meta } 177 188 }, 178 189 }) ··· 255 266 ...result.options, 256 267 fonts: [...(defaultFont ? [defaultFont] : []), ...(result.options?.fonts || [])], 257 268 } 258 - const svg = await satori(element as any, options) 269 + const svg = await (await getSatori())(element as any, options) 259 270 return { svg, meta: result.meta } 260 271 }, 261 272 }) ··· 284 295 285 296 try { 286 297 const { svg, meta } = await handler.execute(params) 298 + const Resvg = await getResvg() 287 299 const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng() 288 300 289 301 if (cache.size >= CACHE_MAX) {
+248
packages/hatk/src/pds-proxy.ts
··· 1 + // Shared PDS proxy functions — used by both HTTP route handlers and XRPC handlers. 2 + 3 + import type { OAuthConfig } from './config.ts' 4 + import { getSession, getServerKey } from './oauth/db.ts' 5 + import { createDpopProof } from './oauth/dpop.ts' 6 + import { refreshPdsSession } from './oauth/server.ts' 7 + import { validateRecord } from '@bigmoves/lexicon' 8 + import { getLexiconArray } from './database/schema.ts' 9 + import { insertRecord, deleteRecord as dbDeleteRecord } from './database/db.ts' 10 + 11 + export class ProxyError extends Error { 12 + constructor(public status: number, message: string) { 13 + super(message) 14 + } 15 + } 16 + 17 + // --- Low-level PDS proxy with DPoP + nonce retry + token refresh --- 18 + 19 + async function proxyToPds( 20 + oauthConfig: OAuthConfig, 21 + session: any, 22 + method: string, 23 + pdsUrl: string, 24 + body: any, 25 + ): Promise<{ ok: boolean; status: number; body: any; headers: Headers }> { 26 + const serverKey = await getServerKey('appview-oauth-key') 27 + const privateJwk = JSON.parse(serverKey!.privateKey) 28 + const publicJwk = JSON.parse(serverKey!.publicKey) 29 + 30 + let accessToken = session.access_token 31 + 32 + async function doFetch( 33 + token: string, 34 + nonce?: string, 35 + ): Promise<{ ok: boolean; status: number; body: any; headers: Headers }> { 36 + const proof = await createDpopProof(privateJwk, publicJwk, method, pdsUrl, token, nonce) 37 + const res = await fetch(pdsUrl, { 38 + method, 39 + headers: { 40 + 'Content-Type': 'application/json', 41 + Authorization: `DPoP ${token}`, 42 + DPoP: proof, 43 + }, 44 + body: JSON.stringify(body), 45 + }) 46 + const resBody = await res.json().catch(() => ({})) 47 + return { ok: res.ok, status: res.status, body: resBody, headers: res.headers } 48 + } 49 + 50 + let result = await doFetch(accessToken) 51 + if (result.ok) return result 52 + 53 + let nonce: string | undefined 54 + 55 + // Step 1: handle DPoP nonce requirement 56 + if (result.body.error === 'use_dpop_nonce') { 57 + nonce = result.headers.get('DPoP-Nonce') || undefined 58 + if (nonce) { 59 + result = await doFetch(accessToken, nonce) 60 + if (result.ok) return result 61 + } 62 + } 63 + 64 + // Step 2: handle expired PDS token — refresh and retry 65 + if (result.body.error === 'invalid_token') { 66 + const refreshed = await refreshPdsSession(oauthConfig, session) 67 + if (refreshed) { 68 + accessToken = refreshed.accessToken 69 + result = await doFetch(accessToken, nonce) 70 + if (result.ok) return result 71 + // May need DPoP nonce after refresh 72 + if (result.body.error === 'use_dpop_nonce') { 73 + nonce = result.headers.get('DPoP-Nonce') || undefined 74 + if (nonce) result = await doFetch(accessToken, nonce) 75 + } 76 + } 77 + } 78 + 79 + return result 80 + } 81 + 82 + /** Proxy a raw binary request to the user's PDS with DPoP + nonce retry + token refresh. */ 83 + async function proxyToPdsRaw( 84 + oauthConfig: OAuthConfig, 85 + session: { access_token: string; pds_endpoint: string; did: string; refresh_token: string; dpop_jkt: string }, 86 + pdsUrl: string, 87 + body: Uint8Array, 88 + contentType: string, 89 + ): Promise<{ ok: boolean; status: number; body: Record<string, unknown>; headers: Headers }> { 90 + const serverKey = await getServerKey('appview-oauth-key') 91 + const privateJwk = JSON.parse(serverKey!.privateKey) 92 + const publicJwk = JSON.parse(serverKey!.publicKey) 93 + 94 + let accessToken = session.access_token 95 + 96 + async function doFetch( 97 + token: string, 98 + nonce?: string, 99 + ): Promise<{ ok: boolean; status: number; body: Record<string, unknown>; headers: Headers }> { 100 + const proof = await createDpopProof(privateJwk, publicJwk, 'POST', pdsUrl, token, nonce) 101 + const res = await fetch(pdsUrl, { 102 + method: 'POST', 103 + headers: { 104 + 'Content-Type': contentType, 105 + 'Content-Length': String(body.length), 106 + Authorization: `DPoP ${token}`, 107 + DPoP: proof, 108 + }, 109 + body: Buffer.from(body), 110 + }) 111 + const resBody: Record<string, unknown> = await res.json().catch(() => ({})) 112 + return { ok: res.ok, status: res.status, body: resBody, headers: res.headers } 113 + } 114 + 115 + let result = await doFetch(accessToken) 116 + if (result.ok) return result 117 + 118 + let nonce: string | undefined 119 + 120 + if (result.body.error === 'use_dpop_nonce') { 121 + nonce = result.headers.get('DPoP-Nonce') || undefined 122 + if (nonce) { 123 + result = await doFetch(accessToken, nonce) 124 + if (result.ok) return result 125 + } 126 + } 127 + 128 + if (result.body.error === 'invalid_token') { 129 + const refreshed = await refreshPdsSession(oauthConfig, session) 130 + if (refreshed) { 131 + accessToken = refreshed.accessToken 132 + result = await doFetch(accessToken, nonce) 133 + if (result.ok) return result 134 + if (result.body.error === 'use_dpop_nonce') { 135 + nonce = result.headers.get('DPoP-Nonce') || undefined 136 + if (nonce) result = await doFetch(accessToken, nonce) 137 + } 138 + } 139 + } 140 + 141 + return result 142 + } 143 + 144 + // --- High-level proxy functions --- 145 + 146 + export async function pdsCreateRecord( 147 + oauthConfig: OAuthConfig, 148 + viewer: { did: string }, 149 + input: { collection: string; repo?: string; rkey?: string; record: Record<string, any> }, 150 + ): Promise<{ uri?: string; cid?: string }> { 151 + const validationError = validateRecord(getLexiconArray(), input.collection, input.record) 152 + if (validationError) { 153 + throw new ProxyError(400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`) 154 + } 155 + 156 + const session = await getSession(viewer.did) 157 + if (!session) throw new ProxyError(401, 'No PDS session for user') 158 + 159 + const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.createRecord` 160 + const pdsBody = { 161 + repo: viewer.did, 162 + collection: input.collection, 163 + rkey: input.rkey, 164 + record: input.record, 165 + } 166 + 167 + const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody) 168 + if (!pdsRes.ok) throw new ProxyError(pdsRes.status, pdsRes.body.error || 'PDS write failed') 169 + 170 + try { 171 + await insertRecord(input.collection, pdsRes.body.uri, pdsRes.body.cid, viewer.did, input.record) 172 + } catch {} 173 + 174 + return pdsRes.body 175 + } 176 + 177 + export async function pdsDeleteRecord( 178 + oauthConfig: OAuthConfig, 179 + viewer: { did: string }, 180 + input: { collection: string; rkey: string }, 181 + ): Promise<Record<string, unknown>> { 182 + const session = await getSession(viewer.did) 183 + if (!session) throw new ProxyError(401, 'No PDS session for user') 184 + 185 + const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.deleteRecord` 186 + const pdsBody = { 187 + repo: viewer.did, 188 + collection: input.collection, 189 + rkey: input.rkey, 190 + } 191 + 192 + const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody) 193 + if (!pdsRes.ok) throw new ProxyError(pdsRes.status, pdsRes.body.error || 'PDS delete failed') 194 + 195 + try { 196 + const uri = `at://${viewer.did}/${input.collection}/${input.rkey}` 197 + await dbDeleteRecord(input.collection, uri) 198 + } catch {} 199 + 200 + return pdsRes.body 201 + } 202 + 203 + export async function pdsPutRecord( 204 + oauthConfig: OAuthConfig, 205 + viewer: { did: string }, 206 + input: { collection: string; rkey: string; record: Record<string, any>; repo?: string }, 207 + ): Promise<{ uri?: string; cid?: string }> { 208 + const validationError = validateRecord(getLexiconArray(), input.collection, input.record) 209 + if (validationError) { 210 + throw new ProxyError(400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`) 211 + } 212 + 213 + const session = await getSession(viewer.did) 214 + if (!session) throw new ProxyError(401, 'No PDS session for user') 215 + 216 + const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.putRecord` 217 + const pdsBody = { 218 + repo: viewer.did, 219 + collection: input.collection, 220 + rkey: input.rkey, 221 + record: input.record, 222 + } 223 + 224 + const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody) 225 + if (!pdsRes.ok) throw new ProxyError(pdsRes.status, pdsRes.body.error || 'PDS write failed') 226 + 227 + try { 228 + await insertRecord(input.collection, pdsRes.body.uri, pdsRes.body.cid, viewer.did, input.record) 229 + } catch {} 230 + 231 + return pdsRes.body 232 + } 233 + 234 + export async function pdsUploadBlob( 235 + oauthConfig: OAuthConfig, 236 + viewer: { did: string }, 237 + body: Uint8Array, 238 + contentType: string, 239 + ): Promise<{ blob: unknown }> { 240 + const session = await getSession(viewer.did) 241 + if (!session) throw new ProxyError(401, 'No PDS session for user') 242 + 243 + const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.uploadBlob` 244 + const pdsRes = await proxyToPdsRaw(oauthConfig, session, pdsUrl, body, contentType) 245 + if (!pdsRes.ok) throw new ProxyError(pdsRes.status, String(pdsRes.body.error || 'PDS upload failed')) 246 + 247 + return pdsRes.body as { blob: unknown } 248 + }
+73
packages/hatk/src/renderer.ts
··· 1 + import { log } from './logger.ts' 2 + 3 + export interface SSRManifest { 4 + getPreloadTags(url: string): string 5 + } 6 + 7 + export interface RenderResult { 8 + html: string 9 + head?: string 10 + } 11 + 12 + export type RendererHandler = (request: Request, manifest: SSRManifest) => Promise<RenderResult> 13 + 14 + let renderer: RendererHandler | null = null 15 + let ssrManifest: SSRManifest | null = null 16 + 17 + export function defineRenderer(handler: RendererHandler) { 18 + return { __type: 'renderer' as const, handler } 19 + } 20 + 21 + export function registerRenderer(handler: RendererHandler): void { 22 + renderer = handler 23 + log('[renderer] SSR renderer registered') 24 + } 25 + 26 + export function setSSRManifest(manifest: SSRManifest): void { 27 + ssrManifest = manifest 28 + } 29 + 30 + export function getRenderer(): RendererHandler | null { 31 + return renderer 32 + } 33 + 34 + export function getSSRManifest(): SSRManifest | null { 35 + return ssrManifest 36 + } 37 + 38 + /** 39 + * Render an HTML page by calling the user's renderer and assembling the result 40 + * into the index.html template. 41 + * 42 + * @param template - The index.html content (with <!--ssr-outlet--> placeholder) 43 + * @param request - The incoming Request 44 + * @param ogMeta - Optional OG meta tags to inject 45 + * @returns Assembled HTML string, or null if no renderer is registered 46 + */ 47 + export async function renderPage( 48 + template: string, 49 + request: Request, 50 + ogMeta?: string | null, 51 + ): Promise<string | null> { 52 + if (!renderer) return null 53 + 54 + const manifest = ssrManifest || { getPreloadTags: () => '' } 55 + const result = await renderer(request, manifest) 56 + 57 + let html = template 58 + 59 + // Inject SSR head tags (preloads, styles) 60 + if (result.head) { 61 + html = html.replace('</head>', `${result.head}\n</head>`) 62 + } 63 + 64 + // Inject OG meta tags 65 + if (ogMeta) { 66 + html = html.replace('</head>', `${ogMeta}\n</head>`) 67 + } 68 + 69 + // Inject rendered HTML into the outlet 70 + html = html.replace('<!--ssr-outlet-->', result.html) 71 + 72 + return html 73 + }
+77
packages/hatk/src/response.ts
··· 1 + import { gzipSync } from 'node:zlib' 2 + import { normalizeValue } from './database/db.ts' 3 + 4 + /** 5 + * Create a JSON Response with optional gzip compression. 6 + * Mirrors the old jsonResponse/sendJson behavior. 7 + */ 8 + export function json(data: unknown, status = 200, acceptEncoding?: string | null): Response { 9 + const body = Buffer.from(JSON.stringify(data, (_, v) => normalizeValue(v))) 10 + 11 + if (body.length > 1024 && acceptEncoding && /\bgzip\b/.test(acceptEncoding)) { 12 + const compressed = gzipSync(body) 13 + return new Response(compressed, { 14 + status, 15 + headers: { 16 + 'Content-Type': 'application/json', 17 + 'Content-Encoding': 'gzip', 18 + 'Vary': 'Accept-Encoding', 19 + ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}), 20 + }, 21 + }) 22 + } 23 + 24 + return new Response(body, { 25 + status, 26 + headers: { 27 + 'Content-Type': 'application/json', 28 + ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}), 29 + }, 30 + }) 31 + } 32 + 33 + /** Create a JSON error Response. */ 34 + export function jsonError(status: number, message: string, acceptEncoding?: string | null): Response { 35 + return json({ error: message }, status, acceptEncoding) 36 + } 37 + 38 + /** CORS preflight Response. */ 39 + export function cors(): Response { 40 + return new Response(null, { 41 + status: 200, 42 + headers: { 43 + 'Access-Control-Allow-Origin': '*', 44 + 'Access-Control-Allow-Headers': '*', 45 + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 46 + }, 47 + }) 48 + } 49 + 50 + /** Add CORS headers to an existing Response. */ 51 + export function withCors(response: Response): Response { 52 + const headers = new Headers(response.headers) 53 + headers.set('Access-Control-Allow-Origin', '*') 54 + headers.set('Access-Control-Allow-Headers', '*') 55 + headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') 56 + return new Response(response.body, { 57 + status: response.status, 58 + statusText: response.statusText, 59 + headers, 60 + }) 61 + } 62 + 63 + /** Create a static file Response with correct MIME type. */ 64 + export function file(content: Buffer | Uint8Array, contentType: string, cacheControl?: string): Response { 65 + return new Response(Buffer.from(content), { 66 + status: 200, 67 + headers: { 68 + 'Content-Type': contentType, 69 + ...(cacheControl ? { 'Cache-Control': cacheControl } : {}), 70 + }, 71 + }) 72 + } 73 + 74 + /** 404 Not Found. */ 75 + export function notFound(): Response { 76 + return new Response('Not Found', { status: 404 }) 77 + }
+6 -1
packages/hatk/src/scanner.ts
··· 16 16 setup: ScannedModule[] 17 17 labels: ScannedModule[] 18 18 og: ScannedModule[] 19 + renderer: ScannedModule | null 19 20 } 20 21 21 22 /** Recursively collect .ts/.js files, skipping _ prefixed and dot files */ ··· 48 49 setup: [], 49 50 labels: [], 50 51 og: [], 52 + renderer: null, 51 53 } 52 54 53 55 if (!existsSync(serverDir)) return result ··· 56 58 57 59 for (const filePath of files) { 58 60 const name = relative(serverDir, filePath).replace(/\.(ts|js)$/, '') 59 - const mod = await import(filePath) 61 + const mod = await import(/* @vite-ignore */ `${filePath}?t=${Date.now()}`) 60 62 const exported = mod.default 61 63 62 64 if (!exported) { ··· 87 89 break 88 90 case 'og': 89 91 result.og.push(entry) 92 + break 93 + case 'renderer': 94 + result.renderer = entry 90 95 break 91 96 default: 92 97 log(`[scanner] ${name}: no recognized __type tag, skipping`)
+12 -4
packages/hatk/src/server-init.ts
··· 7 7 import { registerOgHandler } from './opengraph.ts' 8 8 import { registerHook } from './hooks.ts' 9 9 import { runSetupHandler } from './setup.ts' 10 + import { registerRenderer } from './renderer.ts' 10 11 11 12 /** 12 13 * Scan the server/ directory and register all discovered handlers. 13 14 * Setup scripts run immediately (in sorted order). 14 15 */ 15 - export async function initServer(serverDir: string): Promise<void> { 16 + export async function initServer(serverDir: string, opts?: { skipSetup?: boolean }): Promise<void> { 16 17 if (!existsSync(serverDir)) { 17 18 log(`[server] No server/ directory found, skipping`) 18 19 return ··· 20 21 21 22 const scanned = await scanServerDir(serverDir) 22 23 23 - // 1. Run setup scripts first (sorted by name) 24 - for (const entry of scanned.setup.sort((a, b) => a.name.localeCompare(b.name))) { 25 - await runSetupHandler(entry.name, entry.mod.handler) 24 + // 1. Run setup scripts first (sorted by name) — skipped in test context 25 + if (!opts?.skipSetup) { 26 + for (const entry of scanned.setup.sort((a, b) => a.name.localeCompare(b.name))) { 27 + await runSetupHandler(entry.name, entry.mod.handler) 28 + } 26 29 } 27 30 28 31 // 2. Register feeds ··· 52 55 // 6. Register OG handlers 53 56 for (const entry of scanned.og) { 54 57 registerOgHandler(entry.mod) 58 + } 59 + 60 + // 7. Register renderer 61 + if (scanned.renderer) { 62 + registerRenderer(scanned.renderer.mod.handler) 55 63 } 56 64 57 65 log(`[server] Initialized from server/ directory:`)
+411 -664
packages/hatk/src/server.ts
··· 1 - import { createServer, type Server, type IncomingMessage } from 'node:http' 2 - import { gzipSync } from 'node:zlib' 3 1 import { existsSync } from 'node:fs' 4 2 import { readFile } from 'node:fs/promises' 5 3 import { join, extname } from 'node:path' ··· 13 11 getRepoStatus, 14 12 getRepoRetryInfo, 15 13 querySQL, 16 - insertRecord, 17 - deleteRecord, 18 14 queryLabelsForUris, 19 15 insertLabels, 20 16 searchAccounts, 21 17 listReposPaginated, 22 18 getCollectionCounts, 23 - normalizeValue, 24 19 getSchemaDump, 25 20 getPreferences, 26 21 putPreference, 27 22 } from './database/db.ts' 28 23 import { executeFeed, listFeeds } from './feeds.ts' 29 - import { executeXrpc, InvalidRequestError } from './xrpc.ts' 30 - import { getLexiconArray } from './database/schema.ts' 31 - import { validateRecord } from '@bigmoves/lexicon' 24 + import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler } from './xrpc.ts' 32 25 import { resolveRecords } from './hydrate.ts' 33 26 import { handleOpengraphRequest, buildOgMeta } from './opengraph.ts' 34 27 import { getLabelDefinitions, rescanLabels } from './labels.ts' ··· 42 35 handlePar, 43 36 buildAuthorizeRedirect, 44 37 handleCallback, 38 + serverLogin, 45 39 handleToken, 46 40 authenticate, 47 - refreshPdsSession, 48 41 } from './oauth/server.ts' 42 + import { createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie } from './oauth/session.ts' 49 43 import { getOAuthRequest } from './oauth/db.ts' 50 - import { createDpopProof } from './oauth/dpop.ts' 51 - import { getServerKey, getSession } from './oauth/db.ts' 52 44 import type { OAuthConfig } from './config.ts' 45 + import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsUploadBlob, ProxyError } from './pds-proxy.ts' 46 + import { json, jsonError, cors, withCors, file, notFound } from './response.ts' 47 + import { serve } from './adapter.ts' 48 + import { renderPage } from './renderer.ts' 53 49 54 50 const MIME: Record<string, string> = { 55 51 '.html': 'text/html', ··· 58 54 '.json': 'application/json', 59 55 } 60 56 61 - function readBody(req: any): Promise<string> { 62 - return new Promise((resolve, reject) => { 63 - let body = '' 64 - req.on('data', (chunk: string) => (body += chunk)) 65 - req.on('end', () => resolve(body)) 66 - req.on('error', reject) 57 + /** 58 + * Register built-in dev.hatk.* XRPC handlers in the handler registry. 59 + * This makes them available to callXrpc() for use in SSR and server code. 60 + */ 61 + export function registerCoreHandlers(collections: string[], oauth: OAuthConfig | null): void { 62 + registerCoreXrpcHandler('dev.hatk.getRecords', async (params, cursor, limit) => { 63 + const collection = params.collection 64 + if (!collection) throw new InvalidRequestError('Missing collection parameter') 65 + if (!getSchema(collection)) throw new NotFoundError(`Unknown collection: ${collection}`) 66 + 67 + const sort = params.sort || undefined 68 + const order = (params.order || undefined) as 'asc' | 'desc' | undefined 69 + const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']) 70 + const filters: Record<string, string> = {} 71 + for (const [key, value] of Object.entries(params)) { 72 + if (!reserved.has(key)) filters[key] = value 73 + } 74 + 75 + const result = await queryRecords(collection, { 76 + limit, 77 + cursor, 78 + sort, 79 + order, 80 + filters: Object.keys(filters).length > 0 ? filters : undefined, 81 + }) 82 + const uris = result.records.map((r: any) => r.uri) 83 + const items = await resolveRecords(uris) 84 + return { items, cursor: result.cursor } 85 + }) 86 + 87 + registerCoreXrpcHandler('dev.hatk.getRecord', async (params) => { 88 + const uri = params.uri 89 + if (!uri) throw new InvalidRequestError('Missing uri parameter') 90 + const record = await getRecordByUri(uri) 91 + if (!record) throw new NotFoundError('Record not found') 92 + const shaped = reshapeRow(record, record?.__childData) as Record<string, any> 93 + const labelsMap = await queryLabelsForUris([record.uri]) 94 + if (shaped) shaped.labels = labelsMap.get(record.uri) || [] 95 + return { record: shaped } 96 + }) 97 + 98 + registerCoreXrpcHandler('dev.hatk.getFeed', async (params, cursor, limit, viewer) => { 99 + const feedName = params.feed 100 + if (!feedName) throw new InvalidRequestError('Missing feed parameter') 101 + const result = await executeFeed(feedName, params, cursor, limit, viewer) 102 + if (!result) throw new NotFoundError(`Unknown feed: ${feedName}`) 103 + return result 104 + }) 105 + 106 + registerCoreXrpcHandler('dev.hatk.searchRecords', async (params, cursor, limit) => { 107 + const collection = params.collection 108 + const q = params.q 109 + if (!collection) throw new InvalidRequestError('Missing collection parameter') 110 + if (!q) throw new InvalidRequestError('Missing q parameter') 111 + if (!getSchema(collection)) throw new NotFoundError(`Unknown collection: ${collection}`) 112 + 113 + const fuzzy = params.fuzzy !== 'false' 114 + const result = await searchRecords(collection, q, { limit, cursor, fuzzy }) 115 + const uris = result.records.map((r: any) => r.uri) 116 + const items = await resolveRecords(uris) 117 + return { items, cursor: result.cursor } 118 + }) 119 + 120 + registerCoreXrpcHandler('dev.hatk.describeFeeds', async () => { 121 + return { feeds: listFeeds() } 122 + }) 123 + 124 + registerCoreXrpcHandler('dev.hatk.describeCollections', async () => { 125 + const collectionInfo = collections.map((c) => { 126 + const schema = getSchema(c) 127 + return { 128 + collection: c, 129 + columns: schema?.columns.map((col) => ({ 130 + name: col.name, 131 + originalName: col.originalName, 132 + type: col.sqlType, 133 + required: col.notNull, 134 + })), 135 + } 136 + }) 137 + return { collections: collectionInfo } 67 138 }) 68 - } 69 139 70 - function readBodyRaw(req: IncomingMessage): Promise<Buffer> { 71 - return new Promise((resolve, reject) => { 72 - const chunks: Buffer[] = [] 73 - req.on('data', (chunk: Buffer) => chunks.push(chunk)) 74 - req.on('end', () => resolve(Buffer.concat(chunks))) 75 - req.on('error', reject) 140 + registerCoreXrpcHandler('dev.hatk.describeLabels', async () => { 141 + return { definitions: getLabelDefinitions() } 76 142 }) 143 + 144 + // Write operations — proxy to user's PDS 145 + if (oauth) { 146 + registerCoreXrpcHandler('dev.hatk.getPreferences', async (_params, _cursor, _limit, viewer) => { 147 + if (!viewer) throw new InvalidRequestError('Authentication required') 148 + const prefs = await getPreferences(viewer.did) 149 + return { preferences: prefs } 150 + }) 151 + 152 + registerCoreXrpcHandler('dev.hatk.putPreference', async (_params, _cursor, _limit, viewer, input) => { 153 + if (!viewer) throw new InvalidRequestError('Authentication required') 154 + const body = input as { key?: string; value?: unknown } 155 + if (!body.key || typeof body.key !== 'string') throw new InvalidRequestError('Missing or invalid key') 156 + if (body.value === undefined) throw new InvalidRequestError('Missing value') 157 + await putPreference(viewer.did, body.key, body.value) 158 + return { success: true } 159 + }) 160 + 161 + registerCoreXrpcHandler('dev.hatk.createRecord', async (_params, _cursor, _limit, viewer, input) => { 162 + if (!viewer) throw new InvalidRequestError('Authentication required') 163 + return pdsCreateRecord(oauth, viewer, input as any) 164 + }) 165 + 166 + registerCoreXrpcHandler('dev.hatk.deleteRecord', async (_params, _cursor, _limit, viewer, input) => { 167 + if (!viewer) throw new InvalidRequestError('Authentication required') 168 + return pdsDeleteRecord(oauth, viewer, input as any) 169 + }) 170 + 171 + registerCoreXrpcHandler('dev.hatk.putRecord', async (_params, _cursor, _limit, viewer, input) => { 172 + if (!viewer) throw new InvalidRequestError('Authentication required') 173 + return pdsPutRecord(oauth, viewer, input as any) 174 + }) 175 + 176 + registerCoreXrpcHandler('dev.hatk.uploadBlob', async (_params, _cursor, _limit, viewer, input) => { 177 + if (!viewer) throw new InvalidRequestError('Authentication required') 178 + return pdsUploadBlob(oauth, viewer, input as any, 'application/octet-stream') 179 + }) 180 + } 77 181 } 78 182 79 - export function startServer( 80 - port: number, 81 - collections: string[], 82 - publicDir: string | null, 83 - oauth: OAuthConfig | null, 84 - admins: string[] = [], 85 - resolveViewer?: (req: IncomingMessage) => { did: string } | null, 86 - onResync?: () => void, 87 - ): Server { 88 - const coreXrpc = (method: string) => `/xrpc/dev.hatk.${method}` 183 + export interface HandlerConfig { 184 + collections: string[] 185 + publicDir: string | null 186 + oauth: OAuthConfig | null 187 + admins: string[] 188 + renderer?: (request: Request, manifest: any) => Promise<{ html: string; head?: string }> 189 + resolveViewer?: (request: Request) => { did: string } | null 190 + onResync?: () => void 191 + } 89 192 193 + /** 194 + * Create a Web Standard request handler for all hatk routes. 195 + * Returns a pure function: (Request) → Promise<Response> 196 + */ 197 + export function createHandler(config: HandlerConfig): (request: Request) => Promise<Response> { 198 + const { collections, publicDir, oauth, admins } = config 90 199 const devMode = process.env.DEV_MODE === '1' 200 + const coreXrpc = (method: string) => `/xrpc/dev.hatk.${method}` 91 201 92 - function requireAdmin(viewer: { did: string } | null, res: any): boolean { 93 - if (!viewer) { 94 - jsonError(res, 401, 'Authentication required') 95 - return false 96 - } 97 - if (!devMode && !admins.includes(viewer.did)) { 98 - jsonError(res, 403, 'Admin access required') 99 - return false 100 - } 101 - return true 202 + function requireAdmin(viewer: { did: string } | null, acceptEncoding: string | null): Response | null { 203 + if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 204 + if (!devMode && !admins.includes(viewer.did)) return withCors(jsonError(403, 'Admin access required', acceptEncoding)) 205 + return null // auth OK 102 206 } 103 207 104 - const server = createServer(async (req, res) => { 105 - const url = new URL(req.url!, `http://localhost:${port}`) 208 + return async (request: Request): Promise<Response> => { 209 + const url = new URL(request.url) 210 + const acceptEncoding = request.headers.get('accept-encoding') 106 211 107 - res.setHeader('Access-Control-Allow-Origin', '*') 108 - res.setHeader('Access-Control-Allow-Headers', '*') 109 - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') 110 - if (req.method === 'OPTIONS') { 111 - res.writeHead(200) 112 - res.end() 113 - return 114 - } 212 + // CORS preflight 213 + if (request.method === 'OPTIONS') return cors() 115 214 116 215 const isXrpc = url.pathname.startsWith('/xrpc/') 117 216 const isAdmin = 118 217 url.pathname.startsWith('/admin') && !url.pathname.endsWith('.html') && !url.pathname.endsWith('.js') 119 218 const elapsed = isXrpc || isAdmin ? timer() : null 120 219 let error: string | undefined 121 - const requestOrigin = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['host'] || `localhost:${port}`}` 220 + const requestOrigin = `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host') || 'localhost'}` 122 221 123 222 // Authenticate viewer (optional — unauthenticated requests still work) 124 - let viewer: { did: string } | null = resolveViewer?.(req) ?? null 223 + let viewer: { did: string } | null = config.resolveViewer?.(request) ?? null 125 224 if (!viewer && oauth) { 126 225 try { 127 226 viewer = await authenticate( 128 - (req.headers['authorization'] as string) || null, 129 - (req.headers['dpop'] as string) || null, 130 - req.method!, 227 + request.headers.get('authorization'), 228 + request.headers.get('dpop'), 229 + request.method, 131 230 `${requestOrigin}${url.pathname}`, 132 231 ) 133 232 } catch (err: any) { 134 233 emit('oauth', 'authenticate_error', { error: err.message }) 135 234 } 136 235 } 236 + // Fallback: resolve viewer from session cookie (for browser requests without DPoP) 237 + if (!viewer && oauth) { 238 + try { 239 + viewer = await parseSessionCookie(request) 240 + } catch {} 241 + } 137 242 138 243 try { 139 244 // GET /xrpc/dev.hatk.getRecords?collection=<nsid>&limit=N&cursor=C&<field>=<value> 140 245 if (url.pathname === coreXrpc('getRecords')) { 141 246 const collection = url.searchParams.get('collection') 142 - if (!collection) { 143 - jsonError(res, 400, 'Missing collection parameter') 144 - return 145 - } 146 - if (!getSchema(collection)) { 147 - jsonError(res, 404, `Unknown collection: ${collection}`) 148 - return 149 - } 247 + if (!collection) return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding)) 248 + if (!getSchema(collection)) return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding)) 150 249 151 250 const limit = parseInt(url.searchParams.get('limit') || '20') 152 251 const cursor = url.searchParams.get('cursor') || undefined ··· 170 269 171 270 const uris = result.records.map((r: any) => r.uri) 172 271 const items = await resolveRecords(uris) 173 - jsonResponse(res, { items, cursor: result.cursor }) 174 - return 272 + return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding)) 175 273 } 176 274 177 275 // GET /xrpc/dev.hatk.getRecord?uri=<at-uri> 178 276 if (url.pathname === coreXrpc('getRecord')) { 179 277 const uri = url.searchParams.get('uri') 180 - if (!uri) { 181 - jsonError(res, 400, 'Missing uri parameter') 182 - return 183 - } 278 + if (!uri) return withCors(jsonError(400, 'Missing uri parameter', acceptEncoding)) 184 279 185 280 const record = await getRecordByUri(uri) 186 - if (!record) { 187 - jsonError(res, 404, 'Record not found') 188 - return 189 - } 281 + if (!record) return withCors(jsonError(404, 'Record not found', acceptEncoding)) 190 282 191 283 const shaped = reshapeRow(record, record?.__childData) as Record<string, any> 192 284 const labelsMap = await queryLabelsForUris([record.uri]) 193 285 if (shaped) shaped.labels = labelsMap.get(record.uri) || [] 194 - jsonResponse(res, { record: shaped }) 195 - return 286 + return withCors(json({ record: shaped }, 200, acceptEncoding)) 196 287 } 197 288 198 289 // GET /xrpc/dev.hatk.getFeed?feed=<name>&limit=N&cursor=C 199 290 if (url.pathname === coreXrpc('getFeed')) { 200 291 const feedName = url.searchParams.get('feed') 201 - if (!feedName) { 202 - jsonError(res, 400, 'Missing feed parameter') 203 - return 204 - } 292 + if (!feedName) return withCors(jsonError(400, 'Missing feed parameter', acceptEncoding)) 205 293 const limit = parseInt(url.searchParams.get('limit') || '30') 206 294 const cursor = url.searchParams.get('cursor') || undefined 207 295 ··· 211 299 } 212 300 213 301 const result = await executeFeed(feedName, params, cursor, limit, viewer) 214 - if (!result) { 215 - jsonError(res, 404, `Unknown feed: ${feedName}`) 216 - return 217 - } 302 + if (!result) return withCors(jsonError(404, `Unknown feed: ${feedName}`, acceptEncoding)) 218 303 219 - jsonResponse(res, result) 220 - return 304 + return withCors(json(result, 200, acceptEncoding)) 221 305 } 222 306 223 307 // GET /xrpc/dev.hatk.searchRecords?collection=<nsid>&q=<query>&limit=N&cursor=C 224 308 if (url.pathname === coreXrpc('searchRecords')) { 225 309 const collection = url.searchParams.get('collection') 226 310 const q = url.searchParams.get('q') 227 - if (!collection) { 228 - jsonError(res, 400, 'Missing collection parameter') 229 - return 230 - } 231 - if (!q) { 232 - jsonError(res, 400, 'Missing q parameter') 233 - return 234 - } 235 - if (!getSchema(collection)) { 236 - jsonError(res, 404, `Unknown collection: ${collection}`) 237 - return 238 - } 311 + if (!collection) return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding)) 312 + if (!q) return withCors(jsonError(400, 'Missing q parameter', acceptEncoding)) 313 + if (!getSchema(collection)) return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding)) 239 314 240 315 const limit = parseInt(url.searchParams.get('limit') || '20') 241 316 const cursor = url.searchParams.get('cursor') || undefined ··· 245 320 246 321 const uris = result.records.map((r: any) => r.uri) 247 322 const items = await resolveRecords(uris) 248 - jsonResponse(res, { items, cursor: result.cursor }) 249 - return 323 + return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding)) 250 324 } 251 325 252 326 // GET /xrpc/dev.hatk.describeFeeds 253 327 if (url.pathname === coreXrpc('describeFeeds')) { 254 - jsonResponse(res, { feeds: listFeeds() }) 255 - return 328 + return withCors(json({ feeds: listFeeds() }, 200, acceptEncoding)) 256 329 } 257 330 258 331 // GET /xrpc/dev.hatk.describeCollections ··· 269 342 })), 270 343 } 271 344 }) 272 - jsonResponse(res, { collections: collectionInfo }) 273 - return 345 + return withCors(json({ collections: collectionInfo }, 200, acceptEncoding)) 274 346 } 275 347 276 348 // GET /xrpc/dev.hatk.describeLabels 277 349 if (url.pathname === coreXrpc('describeLabels')) { 278 - jsonResponse(res, { definitions: getLabelDefinitions() }) 279 - return 350 + return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding)) 280 351 } 281 352 282 353 // GET /xrpc/dev.hatk.getPreferences — get all preferences for authenticated user 283 354 if (url.pathname === coreXrpc('getPreferences')) { 284 - if (!viewer) { 285 - jsonError(res, 401, 'Authentication required') 286 - return 287 - } 355 + if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 288 356 const prefs = await getPreferences(viewer.did) 289 - jsonResponse(res, { preferences: prefs }) 290 - return 357 + return withCors(json({ preferences: prefs }, 200, acceptEncoding)) 291 358 } 292 359 293 360 // POST /xrpc/dev.hatk.putPreference — set a single preference 294 - if (url.pathname === coreXrpc('putPreference') && req.method === 'POST') { 295 - if (!viewer) { 296 - jsonError(res, 401, 'Authentication required') 297 - return 298 - } 299 - const body = JSON.parse(await readBody(req)) 300 - if (!body.key || typeof body.key !== 'string') { 301 - jsonError(res, 400, 'Missing or invalid key') 302 - return 303 - } 304 - if (body.value === undefined) { 305 - jsonError(res, 400, 'Missing value') 306 - return 307 - } 361 + if (url.pathname === coreXrpc('putPreference') && request.method === 'POST') { 362 + if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 363 + const body = JSON.parse(await request.text()) 364 + if (!body.key || typeof body.key !== 'string') return withCors(jsonError(400, 'Missing or invalid key', acceptEncoding)) 365 + if (body.value === undefined) return withCors(jsonError(400, 'Missing value', acceptEncoding)) 308 366 await putPreference(viewer.did, body.key, body.value) 309 - jsonResponse(res, { success: true }) 310 - return 367 + return withCors(json({ success: true }, 200, acceptEncoding)) 311 368 } 312 369 313 370 // ── Admin Repo Management ── 314 371 315 372 // POST /admin/repos/add — enqueue DIDs for backfill 316 - if (url.pathname === '/admin/repos/add' && req.method === 'POST') { 317 - if (!requireAdmin(viewer, res)) return 318 - const { dids } = JSON.parse(await readBody(req)) 319 - if (!Array.isArray(dids)) { 320 - jsonError(res, 400, 'Missing dids array') 321 - return 322 - } 373 + if (url.pathname === '/admin/repos/add' && request.method === 'POST') { 374 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 375 + const { dids } = JSON.parse(await request.text()) 376 + if (!Array.isArray(dids)) return withCors(jsonError(400, 'Missing dids array', acceptEncoding)) 323 377 for (const did of dids) { 324 378 await setRepoStatus(did, 'pending') 325 379 triggerAutoBackfill(did) 326 380 } 327 - jsonResponse(res, { added: dids.length }) 328 - return 381 + return withCors(json({ added: dids.length }, 200, acceptEncoding)) 329 382 } 330 383 331 384 // POST /admin/labels/rescan — retroactively apply label rules 332 - if (url.pathname === '/admin/labels/rescan' && req.method === 'POST') { 333 - if (!requireAdmin(viewer, res)) return 385 + if (url.pathname === '/admin/labels/rescan' && request.method === 'POST') { 386 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 334 387 const result = await rescanLabels(collections) 335 - jsonResponse(res, result) 336 - return 388 + return withCors(json(result, 200, acceptEncoding)) 337 389 } 338 390 339 391 // ── Admin Endpoints ── 340 392 341 393 // GET /admin/whoami — check if current viewer is admin 342 394 if (url.pathname === '/admin/whoami') { 343 - if (!requireAdmin(viewer, res)) return 344 - jsonResponse(res, { did: viewer!.did, admin: true }) 345 - return 395 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 396 + return withCors(json({ did: viewer!.did, admin: true }, 200, acceptEncoding)) 346 397 } 347 398 348 399 // GET /admin/labels/definitions — get available label definitions 349 400 if (url.pathname === '/admin/labels/definitions') { 350 - if (!requireAdmin(viewer, res)) return 351 - jsonResponse(res, { definitions: getLabelDefinitions() }) 352 - return 401 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 402 + return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding)) 353 403 } 354 404 355 405 // POST /admin/labels — apply a label 356 - if (url.pathname === '/admin/labels' && req.method === 'POST') { 357 - if (!requireAdmin(viewer, res)) return 358 - const { uri, val } = JSON.parse(await readBody(req)) 359 - if (!uri || !val) { 360 - jsonError(res, 400, 'Missing uri or val') 361 - return 362 - } 406 + if (url.pathname === '/admin/labels' && request.method === 'POST') { 407 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 408 + const { uri, val } = JSON.parse(await request.text()) 409 + if (!uri || !val) return withCors(jsonError(400, 'Missing uri or val', acceptEncoding)) 363 410 await insertLabels([{ src: 'admin', uri, val }]) 364 - jsonResponse(res, { ok: true }) 365 - return 411 + return withCors(json({ ok: true }, 200, acceptEncoding)) 366 412 } 367 413 368 414 // POST /admin/labels/reset — delete all labels of a given type 369 - if (url.pathname === '/admin/labels/reset' && req.method === 'POST') { 370 - if (!requireAdmin(viewer, res)) return 371 - const { val } = JSON.parse(await readBody(req)) 372 - if (!val) { 373 - jsonError(res, 400, 'Missing val') 374 - return 375 - } 415 + if (url.pathname === '/admin/labels/reset' && request.method === 'POST') { 416 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 417 + const { val } = JSON.parse(await request.text()) 418 + if (!val) return withCors(jsonError(400, 'Missing val', acceptEncoding)) 376 419 const result = await querySQL(`SELECT COUNT(*)::INTEGER as count FROM _labels WHERE val = $1`, [val]) 377 420 const count = Number(result[0]?.count || 0) 378 421 await querySQL(`DELETE FROM _labels WHERE val = $1`, [val]) 379 - jsonResponse(res, { deleted: count }) 380 - return 422 + return withCors(json({ deleted: count }, 200, acceptEncoding)) 381 423 } 382 424 383 425 // POST /admin/labels/negate — negate a label 384 - if (url.pathname === '/admin/labels/negate' && req.method === 'POST') { 385 - if (!requireAdmin(viewer, res)) return 386 - const { uri, val } = JSON.parse(await readBody(req)) 387 - if (!uri || !val) { 388 - jsonError(res, 400, 'Missing uri or val') 389 - return 390 - } 426 + if (url.pathname === '/admin/labels/negate' && request.method === 'POST') { 427 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 428 + const { uri, val } = JSON.parse(await request.text()) 429 + if (!uri || !val) return withCors(jsonError(400, 'Missing uri or val', acceptEncoding)) 391 430 await insertLabels([{ src: 'admin', uri, val, neg: true }]) 392 - jsonResponse(res, { ok: true }) 393 - return 431 + return withCors(json({ ok: true }, 200, acceptEncoding)) 394 432 } 395 433 396 434 // POST /admin/takedown — takedown an account 397 - if (url.pathname === '/admin/takedown' && req.method === 'POST') { 398 - if (!requireAdmin(viewer, res)) return 399 - const { did } = JSON.parse(await readBody(req)) 400 - if (!did) { 401 - jsonError(res, 400, 'Missing did') 402 - return 403 - } 435 + if (url.pathname === '/admin/takedown' && request.method === 'POST') { 436 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 437 + const { did } = JSON.parse(await request.text()) 438 + if (!did) return withCors(jsonError(400, 'Missing did', acceptEncoding)) 404 439 await setRepoStatus(did, 'takendown') 405 - jsonResponse(res, { ok: true }) 406 - return 440 + return withCors(json({ ok: true }, 200, acceptEncoding)) 407 441 } 408 442 409 443 // POST /admin/reverse-takedown — reverse a takedown 410 - if (url.pathname === '/admin/reverse-takedown' && req.method === 'POST') { 411 - if (!requireAdmin(viewer, res)) return 412 - const { did } = JSON.parse(await readBody(req)) 413 - if (!did) { 414 - jsonError(res, 400, 'Missing did') 415 - return 416 - } 444 + if (url.pathname === '/admin/reverse-takedown' && request.method === 'POST') { 445 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 446 + const { did } = JSON.parse(await request.text()) 447 + if (!did) return withCors(jsonError(400, 'Missing did', acceptEncoding)) 417 448 await setRepoStatus(did, 'active') 418 - jsonResponse(res, { ok: true }) 419 - return 449 + return withCors(json({ ok: true }, 200, acceptEncoding)) 420 450 } 421 451 422 452 // GET /admin/search — search records or accounts 423 453 if (url.pathname === '/admin/search') { 424 - if (!requireAdmin(viewer, res)) return 454 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 425 455 const q = url.searchParams.get('q') || '' 426 456 const type = url.searchParams.get('type') || 'records' 427 457 const limit = parseInt(url.searchParams.get('limit') || '20') 428 458 429 459 if (type === 'accounts') { 430 460 const accounts = await searchAccounts(q, limit) 431 - jsonResponse(res, { accounts }) 432 - return 461 + return withCors(json({ accounts }, 200, acceptEncoding)) 433 462 } 434 463 435 464 // No query — live firehose activity (excludes bulk backfill records) ··· 440 469 try { 441 470 const schema = getSchema(col) 442 471 if (!schema) continue 443 - // Only show records indexed after the repo's backfill completed (live activity) 444 472 const rows = await querySQL( 445 473 `SELECT t.* FROM ${schema.tableName} t JOIN _repos r ON t.did = r.did WHERE t.indexed_at > r.backfilled_at ORDER BY t.indexed_at DESC LIMIT $1`, 446 474 [limit + offset], ··· 458 486 return ta > tb ? -1 : ta < tb ? 1 : 0 459 487 }) 460 488 const page = allResults.slice(offset, offset + limit) 461 - jsonResponse(res, { records: page, total: allResults.length }) 462 - return 489 + return withCors(json({ records: page, total: allResults.length }, 200, acceptEncoding)) 463 490 } 464 491 465 492 // URI lookup ··· 467 494 const rec = await getRecordByUri(q) 468 495 if (rec) { 469 496 const labelsMap = await queryLabelsForUris([rec.uri]) 470 - jsonResponse(res, { 497 + return withCors(json({ 471 498 records: [{ ...reshapeRow(rec, rec?.__childData), labels: labelsMap.get(rec.uri) || [] }], 472 - }) 499 + }, 200, acceptEncoding)) 473 500 } else { 474 - jsonResponse(res, { records: [] }) 501 + return withCors(json({ records: [] }, 200, acceptEncoding)) 475 502 } 476 - return 477 503 } 478 504 479 505 // DID lookup — find all records by this DID ··· 492 518 } 493 519 } catch {} 494 520 } 495 - jsonResponse(res, { records: allResults.slice(0, limit) }) 496 - return 521 + return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding)) 497 522 } 498 523 499 524 // Default: full-text search records across all collections ··· 511 536 } 512 537 } catch {} 513 538 } 514 - jsonResponse(res, { records: allResults.slice(0, limit) }) 515 - return 539 + return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding)) 516 540 } 517 541 518 542 // POST /admin/repos/resync — re-download repos 519 - if (url.pathname === '/admin/repos/resync' && req.method === 'POST') { 520 - if (!requireAdmin(viewer, res)) return 521 - const body = await readBody(req) 522 - const { dids } = body ? JSON.parse(body) : ({} as { dids?: string[] }) 543 + if (url.pathname === '/admin/repos/resync' && request.method === 'POST') { 544 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 545 + const bodyText = await request.text() 546 + const { dids } = bodyText ? JSON.parse(bodyText) : ({} as { dids?: string[] }) 523 547 let repoList: string[] 524 548 if (Array.isArray(dids) && dids.length > 0) { 525 549 repoList = dids ··· 530 554 for (const did of repoList) { 531 555 await setRepoStatus(did, 'pending') 532 556 } 533 - jsonResponse(res, { resyncing: repoList.length }) 534 - if (onResync) onResync() 535 - return 557 + if (config.onResync) config.onResync() 558 + return withCors(json({ resyncing: repoList.length }, 200, acceptEncoding)) 536 559 } 537 560 538 561 // POST /admin/repos/remove — remove DIDs from tracking 539 - if (url.pathname === '/admin/repos/remove' && req.method === 'POST') { 540 - if (!requireAdmin(viewer, res)) return 541 - const { dids } = JSON.parse(await readBody(req)) 542 - if (!Array.isArray(dids)) { 543 - jsonError(res, 400, 'Missing dids array') 544 - return 545 - } 562 + if (url.pathname === '/admin/repos/remove' && request.method === 'POST') { 563 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 564 + const { dids } = JSON.parse(await request.text()) 565 + if (!Array.isArray(dids)) return withCors(jsonError(400, 'Missing dids array', acceptEncoding)) 546 566 for (const did of dids) { 547 567 await querySQL(`DELETE FROM _repos WHERE did = $1`, [did]) 548 568 } 549 - jsonResponse(res, { removed: dids.length }) 550 - return 569 + return withCors(json({ removed: dids.length }, 200, acceptEncoding)) 551 570 } 552 571 553 572 // GET /admin/info — aggregate status + db size + collection counts 554 573 if (url.pathname === '/admin/info') { 555 - if (!requireAdmin(viewer, res)) return 574 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 556 575 const rows = await querySQL(`SELECT status, COUNT(*)::INTEGER as count FROM _repos GROUP BY status`) 557 576 const counts: Record<string, number> = {} 558 577 for (const row of rows) counts[row.status as string] = Number(row.count) ··· 566 585 heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`, 567 586 external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`, 568 587 } 569 - jsonResponse(res, { repos: counts, duckdb: dbInfo, node, collections: collectionCounts }) 570 - return 588 + return withCors(json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts }, 200, acceptEncoding)) 571 589 } 572 590 573 591 // GET /admin/info/:did — repo status info 574 592 if (url.pathname.startsWith('/admin/info/did:')) { 575 - if (!requireAdmin(viewer, res)) return 593 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 576 594 const did = url.pathname.slice('/admin/info/'.length) 577 595 const status = await getRepoStatus(did) 578 - if (!status) { 579 - jsonError(res, 404, 'Repo not found') 580 - return 581 - } 596 + if (!status) return withCors(jsonError(404, 'Repo not found', acceptEncoding)) 582 597 const retryInfo = await getRepoRetryInfo(did) 583 - jsonResponse(res, { 598 + return withCors(json({ 584 599 did, 585 600 status, 586 601 retry_count: retryInfo?.retryCount ?? 0, 587 602 retry_after: retryInfo?.retryAfter ?? 0, 588 - }) 589 - return 603 + }, 200, acceptEncoding)) 590 604 } 591 605 592 606 // GET /admin/repos — paginated repo listing 593 - if (url.pathname === '/admin/repos' && req.method === 'GET') { 594 - if (!requireAdmin(viewer, res)) return 607 + if (url.pathname === '/admin/repos' && request.method === 'GET') { 608 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 595 609 const limit = parseInt(url.searchParams.get('limit') || '50') 596 610 const offset = parseInt(url.searchParams.get('offset') || '0') 597 611 const status = url.searchParams.get('status') || undefined 598 612 const q = url.searchParams.get('q') || undefined 599 613 const result = await listReposPaginated({ limit, offset, status, q }) 600 - jsonResponse(res, result) 601 - return 614 + return withCors(json(result, 200, acceptEncoding)) 602 615 } 603 616 604 617 // GET /admin/schema — full DuckDB DDL dump + lexicons 605 618 if (url.pathname === '/admin/schema') { 606 - if (!requireAdmin(viewer, res)) return 619 + const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 607 620 const { getAllLexicons } = await import('./database/schema.ts') 608 621 const ddl = await getSchemaDump() 609 - jsonResponse(res, { ddl, lexicons: getAllLexicons() }) 610 - return 622 + return withCors(json({ ddl, lexicons: getAllLexicons() }, 200, acceptEncoding)) 611 623 } 612 624 613 625 // ── Public Repo Endpoints (used by hatk clients for auto-sync) ── 614 626 615 627 // POST /repos/add — enqueue DIDs for backfill (public) 616 - if (url.pathname === '/repos/add' && req.method === 'POST') { 617 - const { dids } = JSON.parse(await readBody(req)) 618 - if (!Array.isArray(dids)) { 619 - jsonError(res, 400, 'Missing dids array') 620 - return 621 - } 628 + if (url.pathname === '/repos/add' && request.method === 'POST') { 629 + const { dids } = JSON.parse(await request.text()) 630 + if (!Array.isArray(dids)) return withCors(jsonError(400, 'Missing dids array', acceptEncoding)) 622 631 for (const did of dids) { 623 632 await setRepoStatus(did, 'pending') 624 633 triggerAutoBackfill(did) 625 634 } 626 - jsonResponse(res, { added: dids.length }) 627 - return 635 + return withCors(json({ added: dids.length }, 200, acceptEncoding)) 628 636 } 629 637 630 638 // GET /info/:did — repo status info (public) 631 639 if (url.pathname.startsWith('/info/did:')) { 632 640 const did = url.pathname.slice('/info/'.length) 633 641 const status = await getRepoStatus(did) 634 - if (!status) { 635 - jsonError(res, 404, 'Repo not found') 636 - return 637 - } 642 + if (!status) return withCors(jsonError(404, 'Repo not found', acceptEncoding)) 638 643 const retryInfo = await getRepoRetryInfo(did) 639 - jsonResponse(res, { 644 + return withCors(json({ 640 645 did, 641 646 status, 642 647 retry_count: retryInfo?.retryCount ?? 0, 643 648 retry_after: retryInfo?.retryAfter ?? 0, 644 - }) 645 - return 649 + }, 200, acceptEncoding)) 646 650 } 647 651 648 652 // --- OAuth Endpoints --- 649 653 650 654 // OAuth well-known endpoints 651 655 if (url.pathname === '/.well-known/oauth-authorization-server' && oauth) { 652 - jsonResponse(res, getAuthServerMetadata(oauth.issuer, oauth)) 653 - return 656 + return withCors(json(getAuthServerMetadata(oauth.issuer, oauth), 200, acceptEncoding)) 654 657 } 655 658 if (url.pathname === '/.well-known/oauth-protected-resource' && oauth) { 656 - jsonResponse(res, getProtectedResourceMetadata(oauth.issuer, oauth)) 657 - return 659 + return withCors(json(getProtectedResourceMetadata(oauth.issuer, oauth), 200, acceptEncoding)) 658 660 } 659 661 if (url.pathname === '/oauth/jwks' && oauth) { 660 - jsonResponse(res, getJwks()) 661 - return 662 + return withCors(json(getJwks(), 200, acceptEncoding)) 662 663 } 663 664 if ((url.pathname === '/oauth/client-metadata.json' || url.pathname === '/oauth-client-metadata.json') && oauth) { 664 - jsonResponse(res, getClientMetadata(oauth.issuer, oauth)) 665 - return 665 + return withCors(json(getClientMetadata(oauth.issuer, oauth), 200, acceptEncoding)) 666 + } 667 + 668 + // Dev-only: create a session cookie for any DID (for testing) 669 + if (url.pathname === '/__dev/login' && devMode) { 670 + const did = url.searchParams.get('did') 671 + if (!did) return withCors(jsonError(400, 'did required', acceptEncoding)) 672 + const cookieValue = await createSessionCookie(did) 673 + const secure = url.protocol === 'https:' 674 + return new Response(JSON.stringify({ ok: true }), { 675 + status: 200, 676 + headers: { 677 + 'Content-Type': 'application/json', 678 + 'Set-Cookie': sessionCookieHeader(cookieValue, secure), 679 + }, 680 + }) 681 + } 682 + 683 + // OAuth Login (server-initiated, no DPoP required) 684 + if (url.pathname === '/oauth/login' && oauth) { 685 + const handle = url.searchParams.get('handle') 686 + if (!handle) return withCors(jsonError(400, 'handle required', acceptEncoding)) 687 + try { 688 + const redirectUrl = await serverLogin(oauth, handle) 689 + return new Response(null, { status: 302, headers: { Location: redirectUrl } }) 690 + } catch (err: unknown) { 691 + const message = err instanceof Error ? err.message : 'Login failed' 692 + return withCors(jsonError(400, message, acceptEncoding)) 693 + } 666 694 } 667 695 668 696 // OAuth PAR 669 - if (url.pathname === '/oauth/par' && req.method === 'POST' && oauth) { 670 - const rawBody = await readBody(req) 697 + if (url.pathname === '/oauth/par' && request.method === 'POST' && oauth) { 698 + const rawBody = await request.text() 671 699 let body: Record<string, string> 672 - if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) { 700 + if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) { 673 701 body = Object.fromEntries(new URLSearchParams(rawBody)) 674 702 } else { 675 703 body = JSON.parse(rawBody) 676 704 } 677 - const dpopHeader = req.headers['dpop'] as string 678 - if (!dpopHeader) { 679 - jsonError(res, 400, 'DPoP header required') 680 - return 681 - } 705 + const dpopHeader = request.headers.get('dpop') 706 + if (!dpopHeader) return withCors(jsonError(400, 'DPoP header required', acceptEncoding)) 682 707 try { 683 708 const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`) 684 - jsonResponse(res, result) 709 + return withCors(json(result, 200, acceptEncoding)) 685 710 } catch (err: unknown) { 686 711 const message = err instanceof Error ? err.message : 'Unknown error' 687 - jsonError(res, 400, message) 712 + return withCors(jsonError(400, message, acceptEncoding)) 688 713 } 689 - return 690 714 } 691 715 692 716 // OAuth Authorize 693 717 if (url.pathname === '/oauth/authorize' && oauth) { 694 718 const requestUri = url.searchParams.get('request_uri') 695 - if (!requestUri) { 696 - jsonError(res, 400, 'request_uri required') 697 - return 698 - } 699 - const request = await getOAuthRequest(requestUri) 700 - if (!request) { 701 - jsonError(res, 400, 'Invalid or expired request_uri') 702 - return 703 - } 704 - const redirectUrl = buildAuthorizeRedirect(oauth, request) 705 - res.writeHead(302, { Location: redirectUrl }) 706 - res.end() 707 - return 719 + if (!requestUri) return withCors(jsonError(400, 'request_uri required', acceptEncoding)) 720 + const oauthRequest = await getOAuthRequest(requestUri) 721 + if (!oauthRequest) return withCors(jsonError(400, 'Invalid or expired request_uri', acceptEncoding)) 722 + const redirectUrl = buildAuthorizeRedirect(oauth, oauthRequest) 723 + return new Response(null, { status: 302, headers: { Location: redirectUrl } }) 708 724 } 709 725 710 726 // OAuth Callback (PDS redirects here after user approves) 711 727 // Skip if iss matches our own issuer — that's the client-side redirect, let the SPA handle it 712 728 if (url.pathname === '/oauth/callback' && oauth) { 713 729 const iss = url.searchParams.get('iss') 714 - if (iss === oauth.issuer) { 715 - // Client-side callback — fall through to SPA 716 - } else { 730 + if (iss !== oauth.issuer) { 717 731 const code = url.searchParams.get('code') 718 732 const state = url.searchParams.get('state') 719 - if (!code) { 720 - jsonError(res, 400, 'Missing code') 721 - return 722 - } 733 + if (!code) return withCors(jsonError(400, 'Missing code', acceptEncoding)) 723 734 const result = await handleCallback(oauth, code, state, iss) 724 - res.writeHead(302, { Location: result.clientRedirectUri }) 725 - res.end() 726 - return 735 + const isSecure = requestOrigin.startsWith('https') 736 + const cookie = await createSessionCookie(result.did) 737 + // Server-initiated login stores redirectUri as '/' — redirect cleanly without code/iss params 738 + const redirectTo = result.clientRedirectUri.startsWith('/?code=') ? '/' : result.clientRedirectUri 739 + return new Response(null, { 740 + status: 302, 741 + headers: [ 742 + ['Location', redirectTo], 743 + ['Set-Cookie', sessionCookieHeader(cookie, isSecure)], 744 + ], 745 + }) 727 746 } 747 + // Client-side callback — fall through to SPA 748 + } 749 + 750 + // Session cookie logout 751 + if (url.pathname === '/auth/logout' && request.method === 'POST') { 752 + return new Response(null, { 753 + status: 200, 754 + headers: { 'Set-Cookie': clearSessionCookieHeader() }, 755 + }) 728 756 } 729 757 730 758 // OAuth Token 731 - if (url.pathname === '/oauth/token' && req.method === 'POST' && oauth) { 732 - const rawBody = await readBody(req) 759 + if (url.pathname === '/oauth/token' && request.method === 'POST' && oauth) { 760 + const rawBody = await request.text() 733 761 let body: Record<string, string> 734 - if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) { 762 + if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) { 735 763 body = Object.fromEntries(new URLSearchParams(rawBody)) 736 764 } else { 737 765 body = JSON.parse(rawBody) 738 766 } 739 - const dpopHeader = req.headers['dpop'] as string 740 - if (!dpopHeader) { 741 - jsonError(res, 400, 'DPoP header required') 742 - return 743 - } 767 + const dpopHeader = request.headers.get('dpop') 768 + if (!dpopHeader) return withCors(jsonError(400, 'DPoP header required', acceptEncoding)) 744 769 const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`) 745 - jsonResponse(res, result) 746 - return 770 + return withCors(json(result, 200, acceptEncoding)) 747 771 } 748 772 749 773 // POST /xrpc/dev.hatk.createRecord — proxy write to user's PDS 750 - if (url.pathname === coreXrpc('createRecord') && req.method === 'POST' && oauth) { 751 - if (!viewer) { 752 - jsonError(res, 401, 'Authentication required') 753 - return 754 - } 755 - const body = JSON.parse(await readBody(req)) 756 - 757 - const validationError = validateRecord(getLexiconArray(), body.collection, body.record) 758 - if (validationError) { 759 - jsonError( 760 - res, 761 - 400, 762 - `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`, 763 - ) 764 - return 765 - } 766 - 767 - const session = await getSession(viewer.did) 768 - if (!session) { 769 - jsonError(res, 401, 'No PDS session for user') 770 - return 771 - } 772 - 773 - const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.createRecord` 774 - const pdsBody = { 775 - repo: viewer.did, 776 - collection: body.collection, 777 - rkey: body.rkey, 778 - record: body.record, 779 - } 780 - 781 - const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody) 782 - if (!pdsRes.ok) { 783 - jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS write failed') 784 - return 785 - } 786 - const result = pdsRes.body 787 - 788 - // Index the record immediately 774 + if (url.pathname === coreXrpc('createRecord') && request.method === 'POST' && oauth) { 775 + if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 776 + const body = JSON.parse(await request.text()) 789 777 try { 790 - await insertRecord(body.collection, result.uri, result.cid, viewer.did, body.record) 791 - } catch { 792 - // Non-fatal — firehose will catch it 778 + const result = await pdsCreateRecord(oauth, viewer, body) 779 + return withCors(json(result, 200, acceptEncoding)) 780 + } catch (err: any) { 781 + if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 782 + throw err 793 783 } 794 - 795 - jsonResponse(res, result) 796 - return 797 784 } 798 785 799 786 // POST /xrpc/dev.hatk.deleteRecord — proxy delete to user's PDS 800 - if (url.pathname === coreXrpc('deleteRecord') && req.method === 'POST' && oauth) { 801 - if (!viewer) { 802 - jsonError(res, 401, 'Authentication required') 803 - return 804 - } 805 - const body = JSON.parse(await readBody(req)) 806 - const session = await getSession(viewer.did) 807 - if (!session) { 808 - jsonError(res, 401, 'No PDS session for user') 809 - return 810 - } 811 - 812 - const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.deleteRecord` 813 - const pdsBody = { 814 - repo: viewer.did, 815 - collection: body.collection, 816 - rkey: body.rkey, 817 - } 818 - 819 - const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody) 820 - if (!pdsRes.ok) { 821 - jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS delete failed') 822 - return 823 - } 824 - const result = pdsRes.body 825 - 826 - // Delete the record locally 787 + if (url.pathname === coreXrpc('deleteRecord') && request.method === 'POST' && oauth) { 788 + if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 789 + const body = JSON.parse(await request.text()) 827 790 try { 828 - const uri = `at://${viewer.did}/${body.collection}/${body.rkey}` 829 - await deleteRecord(body.collection, uri) 830 - } catch { 831 - // Non-fatal — firehose will catch it 791 + const result = await pdsDeleteRecord(oauth, viewer, body) 792 + return withCors(json(result, 200, acceptEncoding)) 793 + } catch (err: any) { 794 + if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 795 + throw err 832 796 } 833 - 834 - jsonResponse(res, result) 835 - return 836 797 } 837 798 838 799 // POST /xrpc/dev.hatk.putRecord — proxy create-or-update to user's PDS 839 - if (url.pathname === coreXrpc('putRecord') && req.method === 'POST' && oauth) { 840 - if (!viewer) { 841 - jsonError(res, 401, 'Authentication required') 842 - return 843 - } 844 - const body = JSON.parse(await readBody(req)) 845 - 846 - const validationError = validateRecord(getLexiconArray(), body.collection, body.record) 847 - if (validationError) { 848 - jsonError( 849 - res, 850 - 400, 851 - `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`, 852 - ) 853 - return 854 - } 855 - 856 - const session = await getSession(viewer.did) 857 - if (!session) { 858 - jsonError(res, 401, 'No PDS session for user') 859 - return 860 - } 861 - 862 - const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.putRecord` 863 - const pdsBody = { 864 - repo: viewer.did, 865 - collection: body.collection, 866 - rkey: body.rkey, 867 - record: body.record, 868 - } 869 - 870 - const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody) 871 - if (!pdsRes.ok) { 872 - jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS write failed') 873 - return 874 - } 875 - const result = pdsRes.body 876 - 877 - // Re-index (insertRecord uses INSERT OR REPLACE so this handles both create and update) 800 + if (url.pathname === coreXrpc('putRecord') && request.method === 'POST' && oauth) { 801 + if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 802 + const body = JSON.parse(await request.text()) 878 803 try { 879 - await insertRecord(body.collection, result.uri, result.cid, viewer.did, body.record) 880 - } catch { 881 - // Non-fatal — firehose will catch it 804 + const result = await pdsPutRecord(oauth, viewer, body) 805 + return withCors(json(result, 200, acceptEncoding)) 806 + } catch (err: any) { 807 + if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 808 + throw err 882 809 } 883 - 884 - jsonResponse(res, result) 885 - return 886 810 } 887 811 888 812 // POST /xrpc/dev.hatk.uploadBlob — proxy blob upload to user's PDS 889 - if (url.pathname === coreXrpc('uploadBlob') && req.method === 'POST' && oauth) { 890 - if (!viewer) { 891 - jsonError(res, 401, 'Authentication required') 892 - return 813 + if (url.pathname === coreXrpc('uploadBlob') && request.method === 'POST' && oauth) { 814 + if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 815 + const contentType = request.headers.get('content-type') || 'application/octet-stream' 816 + const rawBody = new Uint8Array(await request.arrayBuffer()) 817 + try { 818 + const result = await pdsUploadBlob(oauth, viewer, rawBody, contentType) 819 + return withCors(json(result, 200, acceptEncoding)) 820 + } catch (err: any) { 821 + if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 822 + throw err 893 823 } 894 - 895 - const session = await getSession(viewer.did) 896 - if (!session) { 897 - jsonError(res, 401, 'No PDS session for user') 898 - return 899 - } 900 - 901 - const contentType = req.headers['content-type'] || 'application/octet-stream' 902 - const rawBody = await readBodyRaw(req) 903 - 904 - const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.uploadBlob` 905 - const pdsRes = await proxyToPdsRaw(oauth, session, pdsUrl, rawBody, contentType) 906 - if (!pdsRes.ok) { 907 - jsonError(res, pdsRes.status, String(pdsRes.body.error || 'PDS upload failed')) 908 - return 909 - } 910 - 911 - jsonResponse(res, pdsRes.body) 912 - return 913 824 } 914 825 915 826 // GET /admin — serve admin UI from hatk package ··· 917 828 const adminPath = join(import.meta.dirname, '../public/admin.html') 918 829 try { 919 830 const content = await readFile(adminPath) 920 - res.writeHead(200, { 'Content-Type': 'text/html' }) 921 - res.end(content) 922 - return 831 + return withCors(file(content, 'text/html')) 923 832 } catch { 924 - res.writeHead(404) 925 - res.end('Admin page not found') 926 - return 833 + return withCors(new Response('Admin page not found', { status: 404 })) 927 834 } 928 835 } 929 836 ··· 932 839 const authPath = join(import.meta.dirname, '../public/admin-auth.js') 933 840 try { 934 841 const content = await readFile(authPath) 935 - res.writeHead(200, { 'Content-Type': 'application/javascript' }) 936 - res.end(content) 937 - return 842 + return withCors(file(content, 'application/javascript')) 938 843 } catch { 939 - res.writeHead(404) 940 - res.end('Not found') 941 - return 844 + return notFound() 942 845 } 943 846 } 944 847 945 848 // GET /_health 946 849 if (url.pathname === '/_health') { 947 - jsonResponse(res, { status: 'ok' }) 948 - return 850 + return withCors(json({ status: 'ok' }, 200, acceptEncoding)) 949 851 } 950 852 951 853 // GET /og/* — OpenGraph image routes 952 - if (url.pathname.startsWith('/og/') && !res.writableEnded) { 854 + if (url.pathname.startsWith('/og/')) { 953 855 const png = await handleOpengraphRequest(url.pathname) 954 - if (png) { 955 - res.writeHead(200, { 956 - 'Content-Type': 'image/png', 957 - 'Cache-Control': 'public, max-age=300', 958 - }) 959 - res.end(png) 960 - return 961 - } 856 + if (png) return withCors(file(png, 'image/png', 'public, max-age=300')) 962 857 } 963 858 964 859 // GET/POST /xrpc/{nsid} — custom XRPC handlers (matched by full NSID from folder structure) 965 - if (url.pathname.startsWith('/xrpc/') && !res.writableEnded) { 860 + if (url.pathname.startsWith('/xrpc/')) { 966 861 const method = url.pathname.slice('/xrpc/'.length) 967 862 const limit = parseInt(url.searchParams.get('limit') || '20') 968 863 const cursor = url.searchParams.get('cursor') || undefined ··· 974 869 975 870 // Parse request body for POST (procedures) 976 871 let input: unknown 977 - if (req.method === 'POST') { 872 + if (request.method === 'POST') { 978 873 try { 979 - input = JSON.parse(await readBody(req)) 874 + input = JSON.parse(await request.text()) 980 875 } catch { 981 876 input = {} 982 877 } ··· 984 879 985 880 try { 986 881 const result = await executeXrpc(method, params, cursor, limit, viewer, input) 987 - if (result) { 988 - jsonResponse(res, result) 989 - return 990 - } 882 + if (result) return withCors(json(result, 200, acceptEncoding)) 991 883 } catch (err: any) { 992 884 if (err instanceof InvalidRequestError) { 993 - jsonError(res, err.status, err.errorName || err.message) 994 - return 885 + return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding)) 995 886 } 996 887 throw err 997 888 } ··· 1004 895 const robotsPath = userRobots && existsSync(userRobots) ? userRobots : defaultRobots 1005 896 try { 1006 897 const content = await readFile(robotsPath) 1007 - res.writeHead(200, { 'Content-Type': 'text/plain' }) 1008 - res.end(content) 1009 - return 898 + return withCors(file(content, 'text/plain')) 1010 899 } catch { 1011 900 // fall through 1012 901 } ··· 1017 906 try { 1018 907 const filePath = join(publicDir, url.pathname === '/' ? 'index.html' : url.pathname) 1019 908 const content = await readFile(filePath) 1020 - res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'text/plain' }) 1021 - res.end(content) 1022 - return 909 + return withCors(file(content, MIME[extname(filePath)] || 'text/plain')) 1023 910 } catch {} 1024 911 1025 - // SPA fallback — serve index.html for client-side routes 912 + // SSR or SPA fallback — serve index.html for client-side routes 1026 913 try { 1027 - let content = await readFile(join(publicDir, 'index.html'), 'utf-8') 914 + const template = await readFile(join(publicDir, 'index.html'), 'utf-8') 915 + const ogMeta = buildOgMeta(url.pathname, requestOrigin) 1028 916 1029 - // Inject OG meta tags for shareable routes 1030 - const ogMeta = buildOgMeta(url.pathname, requestOrigin) 1031 - if (ogMeta) { 1032 - content = content.replace('</head>', `${ogMeta}\n</head>`) 917 + // Try SSR first 918 + const renderedHtml = await renderPage(template, request, ogMeta) 919 + if (renderedHtml) { 920 + return withCors(file(Buffer.from(renderedHtml), 'text/html')) 1033 921 } 1034 922 1035 - res.writeHead(200, { 'Content-Type': 'text/html' }) 1036 - res.end(content) 1037 - return 923 + // SPA fallback — inject OG meta only 924 + let html = template 925 + if (ogMeta) { 926 + html = html.replace('</head>', `${ogMeta}\n</head>`) 927 + } 928 + return withCors(file(Buffer.from(html), 'text/html')) 1038 929 } catch {} 1039 930 } 1040 931 1041 - res.writeHead(404) 1042 - res.end('Not Found') 932 + return notFound() 1043 933 } catch (err: any) { 1044 934 error = err.message 1045 - jsonError(res, 500, err.message) 935 + return withCors(jsonError(500, err.message, acceptEncoding)) 1046 936 } finally { 1047 937 if (isXrpc || isAdmin) { 1048 938 emit('server', 'request', { 1049 - method: req.method, 939 + method: request.method, 1050 940 path: url.pathname, 1051 - status_code: res.statusCode, 941 + status_code: 0, // Status not easily available here, but emit for timing 1052 942 duration_ms: elapsed!(), 1053 943 collection: url.searchParams.get('collection') || undefined, 1054 944 query: url.searchParams.get('q') || undefined, ··· 1056 946 }) 1057 947 } 1058 948 } 1059 - }) 1060 - 1061 - server.listen(port, () => log(`[server] ${oauth?.issuer || `http://localhost:${port}`}`)) 1062 - return server 1063 - } 1064 - 1065 - function sendJson(res: any, status: number, body: Buffer): void { 1066 - const acceptEncoding = res.req?.headers['accept-encoding'] || '' 1067 - if (body.length > 1024 && /\bgzip\b/.test(acceptEncoding as string)) { 1068 - const compressed = gzipSync(body) 1069 - res.writeHead(status, { 1070 - 'Content-Type': 'application/json', 1071 - 'Content-Encoding': 'gzip', 1072 - Vary: 'Accept-Encoding', 1073 - ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}), 1074 - }) 1075 - res.end(compressed) 1076 - } else { 1077 - res.writeHead(status, { 1078 - 'Content-Type': 'application/json', 1079 - ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}), 1080 - }) 1081 - res.end(body) 1082 949 } 1083 950 } 1084 951 1085 - function jsonResponse(res: any, data: any): void { 1086 - sendJson(res, 200, Buffer.from(JSON.stringify(data, (_, v) => normalizeValue(v)))) 1087 - } 1088 - 1089 - function jsonError(res: any, status: number, message: string): void { 1090 - if (res.headersSent) return 1091 - sendJson(res, status, Buffer.from(JSON.stringify({ error: message }))) 1092 - } 1093 - 1094 - /** Proxy a request to the user's PDS with DPoP + automatic nonce retry + token refresh. */ 1095 - async function proxyToPds( 1096 - oauthConfig: import('./config.ts').OAuthConfig, 1097 - session: any, 1098 - method: string, 1099 - pdsUrl: string, 1100 - body: any, 1101 - ): Promise<{ ok: boolean; status: number; body: any; headers: Headers }> { 1102 - const serverKey = await getServerKey('appview-oauth-key') 1103 - const privateJwk = JSON.parse(serverKey!.privateKey) 1104 - const publicJwk = JSON.parse(serverKey!.publicKey) 1105 - 1106 - let accessToken = session.access_token 1107 - 1108 - async function doFetch( 1109 - token: string, 1110 - nonce?: string, 1111 - ): Promise<{ ok: boolean; status: number; body: any; headers: Headers }> { 1112 - const proof = await createDpopProof(privateJwk, publicJwk, method, pdsUrl, token, nonce) 1113 - const res = await fetch(pdsUrl, { 1114 - method, 1115 - headers: { 1116 - 'Content-Type': 'application/json', 1117 - Authorization: `DPoP ${token}`, 1118 - DPoP: proof, 1119 - }, 1120 - body: JSON.stringify(body), 1121 - }) 1122 - const resBody = await res.json().catch(() => ({})) 1123 - return { ok: res.ok, status: res.status, body: resBody, headers: res.headers } 1124 - } 1125 - 1126 - let result = await doFetch(accessToken) 1127 - if (result.ok) return result 1128 - 1129 - let nonce: string | undefined 1130 - 1131 - // Step 1: handle DPoP nonce requirement 1132 - if (result.body.error === 'use_dpop_nonce') { 1133 - nonce = result.headers.get('DPoP-Nonce') || undefined 1134 - if (nonce) { 1135 - result = await doFetch(accessToken, nonce) 1136 - if (result.ok) return result 1137 - } 1138 - } 1139 - 1140 - // Step 2: handle expired PDS token — refresh and retry 1141 - if (result.body.error === 'invalid_token') { 1142 - const refreshed = await refreshPdsSession(oauthConfig, session) 1143 - if (refreshed) { 1144 - accessToken = refreshed.accessToken 1145 - result = await doFetch(accessToken, nonce) 1146 - if (result.ok) return result 1147 - // May need DPoP nonce after refresh 1148 - if (result.body.error === 'use_dpop_nonce') { 1149 - nonce = result.headers.get('DPoP-Nonce') || undefined 1150 - if (nonce) result = await doFetch(accessToken, nonce) 1151 - } 1152 - } 1153 - } 1154 - 1155 - return result 952 + // Backward-compatible wrapper 953 + export function startServer( 954 + port: number, 955 + collections: string[], 956 + publicDir: string | null, 957 + oauth: OAuthConfig | null, 958 + admins: string[] = [], 959 + resolveViewer?: (request: Request) => { did: string } | null, 960 + onResync?: () => void, 961 + ): import('node:http').Server { 962 + const handler = createHandler({ collections, publicDir, oauth, admins, resolveViewer, onResync }) 963 + return serve(handler, port) 1156 964 } 1157 965 1158 - /** Proxy a raw binary request to the user's PDS with DPoP + nonce retry + token refresh. */ 1159 - async function proxyToPdsRaw( 1160 - oauthConfig: import('./config.ts').OAuthConfig, 1161 - session: { access_token: string; pds_endpoint: string; did: string; refresh_token: string; dpop_jkt: string }, 1162 - pdsUrl: string, 1163 - body: Uint8Array, 1164 - contentType: string, 1165 - ): Promise<{ ok: boolean; status: number; body: Record<string, unknown>; headers: Headers }> { 1166 - const serverKey = await getServerKey('appview-oauth-key') 1167 - const privateJwk = JSON.parse(serverKey!.privateKey) 1168 - const publicJwk = JSON.parse(serverKey!.publicKey) 1169 - 1170 - let accessToken = session.access_token 1171 - 1172 - async function doFetch( 1173 - token: string, 1174 - nonce?: string, 1175 - ): Promise<{ ok: boolean; status: number; body: Record<string, unknown>; headers: Headers }> { 1176 - const proof = await createDpopProof(privateJwk, publicJwk, 'POST', pdsUrl, token, nonce) 1177 - const res = await fetch(pdsUrl, { 1178 - method: 'POST', 1179 - headers: { 1180 - 'Content-Type': contentType, 1181 - 'Content-Length': String(body.length), 1182 - Authorization: `DPoP ${token}`, 1183 - DPoP: proof, 1184 - }, 1185 - body: Buffer.from(body), 1186 - }) 1187 - const resBody: Record<string, unknown> = await res.json().catch(() => ({})) 1188 - return { ok: res.ok, status: res.status, body: resBody, headers: res.headers } 1189 - } 1190 - 1191 - let result = await doFetch(accessToken) 1192 - if (result.ok) return result 1193 - 1194 - let nonce: string | undefined 1195 - 1196 - if (result.body.error === 'use_dpop_nonce') { 1197 - nonce = result.headers.get('DPoP-Nonce') || undefined 1198 - if (nonce) { 1199 - result = await doFetch(accessToken, nonce) 1200 - if (result.ok) return result 1201 - } 1202 - } 1203 - 1204 - if (result.body.error === 'invalid_token') { 1205 - const refreshed = await refreshPdsSession(oauthConfig, session) 1206 - if (refreshed) { 1207 - accessToken = refreshed.accessToken 1208 - result = await doFetch(accessToken, nonce) 1209 - if (result.ok) return result 1210 - if (result.body.error === 'use_dpop_nonce') { 1211 - nonce = result.headers.get('DPoP-Nonce') || undefined 1212 - if (nonce) result = await doFetch(accessToken, nonce) 1213 - } 1214 - } 1215 - } 1216 - 1217 - return result 1218 - }
+1 -1
packages/hatk/src/setup.ts
··· 80 80 81 81 for (const scriptPath of files) { 82 82 const name = relative(setupDir, scriptPath).replace(/\.(ts|js)$/, '') 83 - const mod = await import(scriptPath) 83 + const mod = await import(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`) 84 84 const handler = mod.default?.handler || mod.default 85 85 if (typeof handler !== 'function') { 86 86 console.warn(`[setup] ${name}: no handler function found, skipping`)
-33
packages/hatk/src/test-browser.ts
··· 1 - import { test as base, expect, type Page } from '@playwright/test' 2 - import type { TestServer } from './test.ts' 3 - import { startTestServer } from './test.ts' 4 - 5 - type WorkerFixtures = { 6 - server: TestServer 7 - } 8 - 9 - /** Inject __TEST_AUTH__ into a page so isLoggedIn() and viewerDid() work. */ 10 - export async function loginAs(page: Page, did: string): Promise<void> { 11 - await page.addInitScript((d: string) => { 12 - ;(window as any).__TEST_AUTH__ = { did: d } 13 - }, did) 14 - } 15 - 16 - /** 17 - * Extended Playwright test with an auto-started hatk server fixture. 18 - * The server starts once per test file (worker scope) and is shared across tests. 19 - */ 20 - export const test = base.extend<{}, WorkerFixtures>({ 21 - // eslint-disable-next-line no-empty-pattern -- Playwright fixture API requires the deps arg 22 - server: [ 23 - async (_deps, use) => { 24 - const server = await startTestServer() 25 - await server.loadFixtures() 26 - await use(server) 27 - await server.close() 28 - }, 29 - { scope: 'worker' }, 30 - ], 31 - }) 32 - 33 - export { expect }
+11 -22
packages/hatk/src/test.ts
··· 1 - import type { IncomingMessage } from 'node:http' 2 1 import { resolve, dirname } from 'node:path' 3 2 import { readdirSync, readFileSync } from 'node:fs' 4 3 import YAML from 'yaml' ··· 12 11 } from './database/schema.ts' 13 12 import { initDatabase, querySQL, runSQL, insertRecord, closeDatabase } from './database/db.ts' 14 13 import { createAdapter } from './database/adapter-factory.ts' 14 + import { SQLITE_DIALECT } from './database/dialect.ts' 15 15 import { setSearchPort } from './database/fts.ts' 16 - import { initFeeds, executeFeed, listFeeds, createPaginate } from './feeds.ts' 17 - import { initXrpc, executeXrpc, listXrpc, configureRelay } from './xrpc.ts' 18 - import { initOpengraph } from './opengraph.ts' 19 - import { initLabels } from './labels.ts' 16 + import { executeFeed, listFeeds, createPaginate } from './feeds.ts' 17 + import { executeXrpc, listXrpc, configureRelay } from './xrpc.ts' 18 + import { initServer } from './server-init.ts' 20 19 import { discoverViews } from './views.ts' 21 - import { loadOnLoginHook } from './hooks.ts' 22 20 import { validateLexicons } from '@bigmoves/lexicon' 23 21 import { packCursor, unpackCursor, isTakendownDid, filterTakendownDids } from './database/db.ts' 24 22 import { seed as createSeedHelpers, type SeedOpts } from './seed.ts' ··· 98 96 if (!lexicon) continue 99 97 const schema = generateTableSchema(nsid, lexicon, lexicons) 100 98 schemas.push(schema) 101 - ddlStatements.push(generateCreateTableSQL(schema)) 99 + ddlStatements.push(generateCreateTableSQL(schema, SQLITE_DIALECT)) 102 100 } 103 101 104 102 // In-memory database 105 - const { adapter, searchPort } = await createAdapter('duckdb') 103 + const { adapter, searchPort } = await createAdapter('sqlite') 106 104 setSearchPort(searchPort) 107 105 await initDatabase(adapter, ':memory:', schemas, ddlStatements) 108 106 109 - // Discover views + hooks 107 + // Discover views 110 108 discoverViews() 111 - try { 112 - await loadOnLoginHook(resolve(configDir, 'hooks')) 113 - } catch {} 114 109 115 - // Skip setup hooks in test context — they're for server boot-time 116 - // initialization (e.g. importing large datasets) and not appropriate for tests 117 - 118 - // Discover feeds, xrpc, labels 119 - await initFeeds(resolve(configDir, 'feeds')) 120 - await initXrpc(resolve(configDir, 'xrpc')) 121 - await initOpengraph(resolve(configDir, 'og')) 122 - await initLabels(resolve(configDir, 'labels')) 110 + // Discover feeds, xrpc, labels, hooks, og from server/ directory (skip setup scripts in tests) 111 + await initServer(resolve(configDir, 'server'), { skipSetup: true }) 123 112 124 113 return { 125 114 db: { query: querySQL, run: runSQL }, ··· 285 274 const { startServer } = await import('./server.ts') 286 275 287 276 // Start server on port 0 (random available port) 288 - const resolveViewer = (req: IncomingMessage) => { 289 - const did = req.headers['x-test-viewer'] 277 + const resolveViewer = (request: Request) => { 278 + const did = request.headers.get('x-test-viewer') 290 279 return typeof did === 'string' ? { did } : null 291 280 } 292 281 const httpServer = startServer(
+262 -74
packages/hatk/src/vite-plugin.ts
··· 1 - import type { Plugin } from 'vite' 2 - import { spawn, type ChildProcess } from 'node:child_process' 1 + import { createRunnableDevEnvironment, type Plugin, type ViteDevServer, type HotUpdateOptions } from 'vite' 3 2 import { resolve } from 'node:path' 4 3 import { existsSync } from 'node:fs' 4 + import { execSync } from 'node:child_process' 5 + 6 + /** Boot the local PDS if a docker-compose.yml exists. */ 7 + async function ensurePds(): Promise<void> { 8 + if (!existsSync(resolve('docker-compose.yml'))) return 9 + try { 10 + const res = await fetch('http://localhost:2583/xrpc/_health') 11 + if (res.ok) return 12 + } catch {} 13 + console.log('[hatk] Starting PDS...') 14 + execSync('docker compose up -d', { stdio: 'inherit', cwd: process.cwd() }) 15 + for (let i = 0; i < 30; i++) { 16 + try { 17 + const res = await fetch('http://localhost:2583/xrpc/_health') 18 + if (res.ok) { 19 + console.log('[hatk] PDS ready') 20 + return 21 + } 22 + } catch {} 23 + await new Promise((r) => setTimeout(r, 1000)) 24 + } 25 + console.error('[hatk] PDS failed to start') 26 + } 27 + 28 + /** Run seed file if it exists. */ 29 + function runSeed(): void { 30 + const seedFile = resolve('seeds/seed.ts') 31 + if (!existsSync(seedFile)) return 32 + try { 33 + execSync(`npx tsx ${seedFile}`, { stdio: 'inherit', cwd: process.cwd() }) 34 + } catch {} 35 + } 36 + 37 + /** Walk all loaded modules in the module graphs to collect CSS URLs for SSR. */ 38 + function collectAllCss(server: ViteDevServer): string { 39 + const cssUrls = new Set<string>() 40 + 41 + for (const envName of ['hatk', 'client']) { 42 + const env = server.environments[envName] 43 + if (!env?.moduleGraph) continue 44 + for (const mod of (env.moduleGraph as any).idToModuleMap?.values?.() ?? []) { 45 + const url = mod.url || '' 46 + if (/\.(css|scss|less|styl|stylus|pcss|postcss)(\?|$)/.test(url)) { 47 + cssUrls.add(url) 48 + } 49 + if (url.includes('type=style')) { 50 + cssUrls.add(url) 51 + } 52 + } 53 + } 54 + 55 + if (cssUrls.size === 0) return '' 56 + return Array.from(cssUrls) 57 + .map((url) => `<link rel="stylesheet" href="${url}">`) 58 + .join('\n') 59 + } 5 60 6 61 export function hatk(opts?: { port?: number }): Plugin { 7 - const devPort = 3000 8 - const backendPort = opts?.port ?? devPort + 1 9 - const issuer = `http://127.0.0.1:${devPort}` 10 - let serverProcess: ChildProcess | null = null 62 + const devPort = opts?.port ?? 3000 63 + let handler: ((request: Request) => Promise<Response>) | null = null 64 + let ssrRenderPage: ((template: string, request: Request) => Promise<string | null>) | null = null 65 + let ssrGetRenderer: (() => any) | null = null 66 + let reloadServer: (() => Promise<void>) | null = null 67 + let reloadTimer: ReturnType<typeof setTimeout> | null = null 11 68 12 69 return { 13 70 name: 'vite-plugin-hatk', 14 71 72 + // Rewrite $hatk imports in source code so SSR module runners can resolve them. 73 + // vite-plus's fetchModule bypasses resolve.alias for bare imports. 74 + transform(code: string, id: string) { 75 + if (!code.includes('$hatk')) return 76 + const hatk = resolve('hatk.generated.ts') 77 + const hatkClient = resolve('hatk.generated.client.ts') 78 + return code 79 + .replace(/from\s+['"](\$hatk\/client)['"]/g, `from '${hatkClient}'`) 80 + .replace(/from\s+['"](\$hatk)['"]/g, `from '${hatk}'`) 81 + }, 82 + 15 83 config() { 16 - const target = `http://127.0.0.1:${backendPort}` 17 - // changeOrigin: false preserves the original Host header so DPoP htu matches 18 - const rule = { target, changeOrigin: false } 19 84 return { 85 + environments: { 86 + hatk: { 87 + resolve: { 88 + conditions: ['svelte'], 89 + noExternal: ['svelte', '@tanstack/svelte-query'], 90 + external: true, 91 + }, 92 + dev: { 93 + createEnvironment(name: string, config: any) { 94 + return createRunnableDevEnvironment(name, config) 95 + }, 96 + optimizeDeps: { 97 + exclude: ['better-sqlite3', '@duckdb/node-api'], 98 + }, 99 + }, 100 + build: { 101 + outDir: 'dist/server', 102 + ssr: true, 103 + rollupOptions: { 104 + external: ['better-sqlite3', '@duckdb/node-api'], 105 + }, 106 + }, 107 + }, 108 + }, 20 109 server: { 21 110 host: '127.0.0.1', 22 111 port: devPort, 112 + fs: { 113 + allow: ['.'], 114 + }, 23 115 watch: { 24 116 ignored: ['**/db/**', '**/data/**'], 25 117 }, 26 - proxy: { 27 - '/xrpc': rule, 28 - '/oauth/par': rule, 29 - '/oauth/token': rule, 30 - '/oauth/jwks': rule, 31 - '/oauth/authorize': rule, 32 - '/oauth/callback': { 33 - ...rule, 34 - // Only proxy the PDS callback (iss !== our issuer) to the backend. 35 - // The client-side callback (iss === our issuer) should reach the SPA. 36 - bypass(req) { 37 - const url = new URL(req.url!, issuer) 38 - if (url.searchParams.get('iss') === issuer) return req.url! 39 - }, 40 - }, 41 - '/oauth/client-metadata.json': rule, 42 - '/oauth-client-metadata.json': rule, 43 - '/.well-known': rule, 44 - '/info': rule, 45 - '/repos': rule, 46 - '/og': rule, 47 - '/admin': rule, 48 - '/_health': rule, 49 - }, 50 - }, 51 - test: { 52 - projects: [ 53 - { 54 - test: { 55 - name: 'unit', 56 - include: ['test/server/**/*.test.ts', 'test/feeds/**/*.test.ts', 'test/xrpc/**/*.test.ts'], 57 - }, 58 - }, 59 - { 60 - test: { 61 - name: 'integration', 62 - include: ['test/integration/**/*.test.ts'], 63 - }, 64 - }, 65 - ], 66 118 }, 67 119 } 68 120 }, 69 121 70 - configureServer(server) { 71 - const mainPath = resolve(import.meta.dirname!, 'main.js') 72 - const watchDirs = ['server', 'xrpc', 'feeds', 'labels', 'jobs', 'setup', 'lexicons'].filter((d) => existsSync(d)) 73 - const watchArgs = watchDirs.flatMap((d) => ['--watch-path', d]) 74 - serverProcess = spawn('npx', ['tsx', 'watch', ...watchArgs, mainPath, 'hatk.config.ts'], { 75 - stdio: 'inherit', 76 - cwd: process.cwd(), 77 - env: { 78 - ...process.env, 79 - PORT: String(backendPort), 80 - OAUTH_ISSUER: process.env.OAUTH_ISSUER || issuer, 81 - DEV_MODE: '1', 82 - }, 83 - }) 122 + async configureServer(server: ViteDevServer) { 123 + // Skip hatk server boot in test mode — tests manage their own context 124 + if (process.env.VITEST) return 84 125 85 - // Suppress ECONNREFUSED proxy errors while backend is booting 86 - const origError = server.config.logger.error 87 - server.config.logger.error = (msg: string, opts?: any) => { 88 - if (typeof msg === 'string' && msg.includes('ECONNREFUSED')) return 89 - origError(msg, opts) 126 + // Boot PDS and run seeds before starting 127 + await ensurePds() 128 + runSeed() 129 + 130 + const env = server.environments.hatk 131 + if (!env || !('runner' in env)) { 132 + console.error('[hatk] hatk environment not available — is Vite 8 with Environment API?') 133 + return 90 134 } 91 135 92 - server.httpServer?.on('close', () => { 93 - serverProcess?.kill() 94 - serverProcess = null 136 + // Load the hatk boot module through the module runner 137 + const mainPath = resolve(import.meta.dirname!, 'dev-entry.js') 138 + const mod = await (env as any).runner.import(mainPath) 139 + handler = mod.handler 140 + ssrRenderPage = mod.renderPage 141 + ssrGetRenderer = mod.getRenderer 142 + reloadServer = mod.reloadServer 143 + 144 + // Expose the runner's callXrpc on globalThis so externalized modules can call XRPC handlers. 145 + // The runner has its own module instances with registered handlers; Node's instance is empty. 146 + ;(globalThis as any).__hatk_callXrpc = mod.callXrpc 147 + 148 + // Capture cookie parser for SSR viewer resolution 149 + const ssrParseSessionCookie: ((request: Request) => Promise<{ did: string } | null>) | null = mod.parseSessionCookie ?? null 150 + ;(globalThis as any).__hatk_parseSessionCookie = ssrParseSessionCookie 151 + 152 + const hasRenderer = ssrGetRenderer && ssrGetRenderer() 153 + if (hasRenderer) { 154 + console.log('[hatk] SSR ready') 155 + } 156 + 157 + // API routes — must run before Vite's static middleware 158 + server.middlewares.use(async (req: any, res: any, next: any) => { 159 + const url = new URL(req.url!, `http://localhost:${devPort}`) 160 + 161 + const isBackend = 162 + url.pathname.startsWith('/xrpc/') || 163 + url.pathname.startsWith('/oauth/') || 164 + url.pathname.startsWith('/.well-known/') || 165 + url.pathname.startsWith('/og/') || 166 + url.pathname.startsWith('/admin') || 167 + url.pathname.startsWith('/repos') || 168 + url.pathname.startsWith('/info/') || 169 + url.pathname === '/_health' || 170 + url.pathname === '/robots.txt' || 171 + url.pathname === '/auth/logout' || 172 + url.pathname.startsWith('/__dev/') 173 + 174 + if (!isBackend || !handler) { 175 + next() 176 + return 177 + } 178 + 179 + try { 180 + const { toRequest, sendResponse } = await import('./adapter.js') 181 + const request = toRequest(req, `http://localhost:${devPort}`) 182 + const response = await handler(request) 183 + if (response.status === 404) { 184 + next() 185 + return 186 + } 187 + await sendResponse(res, response) 188 + } catch (err: any) { 189 + console.error('[hatk]', err.message) 190 + next(err) 191 + } 95 192 }) 193 + 194 + // SSR middleware — returned function runs after htmlFallback but before indexHtmlMiddleware 195 + return () => { 196 + server.middlewares.use(async (req: any, res: any, next: any) => { 197 + if (!hasRenderer) { 198 + next() 199 + return 200 + } 201 + 202 + const accept = req.headers.accept || '' 203 + const url = req.originalUrl || req.url 204 + if (!accept.includes('text/html') || !url) { 205 + next() 206 + return 207 + } 208 + 209 + try { 210 + const { readFileSync } = await import('node:fs') 211 + const rawHtml = readFileSync(resolve('index.html'), 'utf-8') 212 + const template = await server.transformIndexHtml(url, rawHtml) 213 + 214 + const fullUrl = new URL(url, `http://localhost:${devPort}`) 215 + const headers: Record<string, string> = {} 216 + if (req.headers.cookie) headers.cookie = req.headers.cookie 217 + const request = new Request(fullUrl.href, { headers }) 218 + 219 + // Resolve viewer from session cookie for SSR 220 + let viewer: { did: string } | null = null 221 + if (ssrParseSessionCookie) { 222 + try { 223 + viewer = await ssrParseSessionCookie(request) 224 + } catch {} 225 + } 226 + ;(globalThis as any).__hatk_viewer = viewer 227 + 228 + let renderedHtml: string | null 229 + try { 230 + renderedHtml = await ssrRenderPage!(template, request) 231 + } finally { 232 + ;(globalThis as any).__hatk_viewer = null 233 + } 234 + 235 + // Inject viewer into HTML so client has it before OAuth initializes 236 + if (renderedHtml && viewer) { 237 + const script = `<script>globalThis.__hatk_viewer=${JSON.stringify(viewer)}</script>` 238 + renderedHtml = renderedHtml.replace('</head>', `${script}\n</head>`) 239 + } 240 + if (!renderedHtml) { 241 + next() 242 + return 243 + } 244 + 245 + // Collect CSS from all loaded modules to prevent FOUC 246 + const cssLinks = collectAllCss(server) 247 + let html = renderedHtml 248 + if (cssLinks) { 249 + html = html.replace('</head>', `${cssLinks}\n</head>`) 250 + } 251 + 252 + res.setHeader('Content-Type', 'text/html') 253 + res.end(html) 254 + } catch (err: any) { 255 + console.error('[hatk] SSR error:', err.message) 256 + next(err) 257 + } 258 + }) 259 + } 96 260 }, 97 261 98 - buildEnd() { 99 - serverProcess?.kill() 100 - serverProcess = null 262 + // Handle HMR for server/ files in the hatk environment 263 + hotUpdate(this: { environment: any }, options: HotUpdateOptions) { 264 + if (options.file.includes('/server/') && reloadServer) { 265 + // Debounce: hotUpdate fires once per environment, only reload once 266 + if (!reloadTimer) { 267 + reloadTimer = setTimeout(() => { 268 + reloadTimer = null 269 + reloadServer!().then(() => { 270 + console.log('[hatk] Server handlers reloaded') 271 + }).catch((err: any) => { 272 + console.error('[hatk] Failed to reload server handlers:', err.message) 273 + }) 274 + }, 50) 275 + } 276 + } 277 + }, 278 + 279 + // Two-stage production build 280 + async buildApp(builder: any) { 281 + // Stage 1: Build client 282 + if (builder.environments.client) { 283 + await builder.build(builder.environments.client) 284 + } 285 + // Stage 2: Build hatk server (if environment exists) 286 + if (builder.environments.hatk) { 287 + await builder.build(builder.environments.hatk) 288 + } 101 289 }, 102 290 } 103 291 }
+41 -1
packages/hatk/src/xrpc.ts
··· 168 168 for (const scriptPath of files) { 169 169 const rel = relative(xrpcDir, scriptPath).replace(/\.(ts|js)$/, '') 170 170 const name = rel.replace(/[\\/]/g, '.') 171 - const mod = await import(scriptPath) 171 + const mod = await import(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`) 172 172 const handler = mod.default 173 173 174 174 // Extract param schema from lexicon for validation and defaults ··· 305 305 const handler = handlers.get(name) 306 306 if (!handler) return null 307 307 return handler.execute(params, cursor, limit, viewer || null, input) 308 + } 309 + 310 + /** Call a registered XRPC handler directly (no HTTP). For use in SSR renderers. */ 311 + export async function callXrpc( 312 + nsid: string, 313 + params: Record<string, any> = {}, 314 + input?: unknown, 315 + ): Promise<any> { 316 + const viewer = (globalThis as any).__hatk_viewer ?? null 317 + // In externalized module context (e.g. SSR), delegate to the runner's callXrpc via globalThis. 318 + // The runner's module instance has all registered handlers; this (Node's) instance may not. 319 + if (handlers.size === 0 && (globalThis as any).__hatk_callXrpc) { 320 + return (globalThis as any).__hatk_callXrpc(nsid, params, input) 321 + } 322 + const stringParams: Record<string, string> = {} 323 + for (const [k, v] of Object.entries(params)) { 324 + if (v != null) stringParams[k] = String(v) 325 + } 326 + const limit = params.limit ? Number(params.limit) : 20 327 + const cursor = params.cursor ?? undefined 328 + const result = await executeXrpc(nsid, stringParams, cursor, limit, viewer, input) 329 + if (result === null) throw new Error(`No XRPC handler registered for ${nsid}`) 330 + return result 331 + } 332 + 333 + /** 334 + * Register a core XRPC handler directly (no XrpcContext wrapping). 335 + * Used for built-in dev.hatk.* handlers that manage their own dependencies. 336 + */ 337 + export function registerCoreXrpcHandler( 338 + nsid: string, 339 + fn: ( 340 + params: Record<string, string>, 341 + cursor: string | undefined, 342 + limit: number, 343 + viewer: { did: string } | null, 344 + input?: unknown, 345 + ) => Promise<any>, 346 + ): void { 347 + handlers.set(nsid, { name: nsid, execute: fn }) 308 348 } 309 349 310 350 /** Return all registered XRPC method names. */