atproto user agency toolkit for individuals and groups
8
fork

Configure Feed

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

Add gossipsub commit notifications for P2P push-based repo sync

Nodes publish lightweight CBOR notifications over gossipsub when commits
occur (local or replicated). Subscribed peers compare rev and trigger
syncDid() if newer, enabling low-latency P2P sync without polling.

- Add @libp2p/gossipsub v15 (compatible with libp2p v3/multiaddr v13)
- Extend NetworkService with publish/subscribe/handler for commit topics
- Publish notifications in RepoManager.sequenceAndBroadcast() and
ReplicationManager after sync
- Subscribe to per-DID topics in ReplicationManager.init() with dedup
- Update libp2p-transport.ts to v3 Stream API (send+close vs sink)
- Add E2E gossipsub test, encoding tests, and integration tests

+1002 -197
+255 -180
package-lock.json
··· 24 24 "@atproto/lex-json": "^0.0.11", 25 25 "@atproto/repo": "^0.8.12", 26 26 "@hono/node-server": "^1.13.8", 27 + "@libp2p/gossipsub": "^15.0.12", 27 28 "bcryptjs": "^3.0.3", 28 29 "better-sqlite3": "^11.8.1", 29 30 "blockstore-fs": "^3.0.2", ··· 429 430 "engines": { 430 431 "node": ">=6.9.0" 431 432 } 432 - }, 433 - "node_modules/@babel/code-frame/node_modules/js-tokens": { 434 - "version": "4.0.0", 435 - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 436 - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 437 - "license": "MIT", 438 - "peer": true 439 433 }, 440 434 "node_modules/@babel/compat-data": { 441 435 "version": "7.29.0", ··· 1596 1590 "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", 1597 1591 "license": "Apache-2.0 OR MIT" 1598 1592 }, 1593 + "node_modules/@helia/delegated-routing-v1-http-api-client/node_modules/p-queue": { 1594 + "version": "9.1.0", 1595 + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", 1596 + "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", 1597 + "license": "MIT", 1598 + "dependencies": { 1599 + "eventemitter3": "^5.0.1", 1600 + "p-timeout": "^7.0.0" 1601 + }, 1602 + "engines": { 1603 + "node": ">=20" 1604 + }, 1605 + "funding": { 1606 + "url": "https://github.com/sponsors/sindresorhus" 1607 + } 1608 + }, 1599 1609 "node_modules/@helia/delegated-routing-v1-http-api-client/node_modules/uint8arrays": { 1600 1610 "version": "5.1.0", 1601 1611 "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", ··· 2121 2131 "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", 2122 2132 "license": "Apache-2.0 OR MIT" 2123 2133 }, 2124 - "node_modules/@libp2p/circuit-relay-v2/node_modules/nanoid": { 2125 - "version": "5.1.6", 2126 - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", 2127 - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", 2128 - "funding": [ 2129 - { 2130 - "type": "github", 2131 - "url": "https://github.com/sponsors/ai" 2132 - } 2133 - ], 2134 - "license": "MIT", 2135 - "bin": { 2136 - "nanoid": "bin/nanoid.js" 2137 - }, 2138 - "engines": { 2139 - "node": "^18 || >=20" 2140 - } 2141 - }, 2142 2134 "node_modules/@libp2p/circuit-relay-v2/node_modules/uint8arrays": { 2143 2135 "version": "5.1.0", 2144 2136 "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", ··· 2250 2242 "url": "https://github.com/sponsors/sindresorhus" 2251 2243 } 2252 2244 }, 2245 + "node_modules/@libp2p/gossipsub": { 2246 + "version": "15.0.12", 2247 + "resolved": "https://registry.npmjs.org/@libp2p/gossipsub/-/gossipsub-15.0.12.tgz", 2248 + "integrity": "sha512-jhyzujQeH/o+WMS9L+m785ygF8W5++Lay6pgnfkgHj1qrtc/IJtMIgsASZaoTTQY+yC6Zy2EL4r5dJ131mBVNQ==", 2249 + "license": "Apache-2.0", 2250 + "dependencies": { 2251 + "@libp2p/crypto": "^5.1.13", 2252 + "@libp2p/interface": "^3.1.0", 2253 + "@libp2p/interface-internal": "^3.0.10", 2254 + "@libp2p/peer-id": "^6.0.4", 2255 + "@libp2p/utils": "^7.0.10", 2256 + "@multiformats/multiaddr": "^13.0.1", 2257 + "denque": "^2.1.0", 2258 + "it-length-prefixed": "^10.0.1", 2259 + "it-pipe": "^3.0.1", 2260 + "it-pushable": "^3.2.3", 2261 + "multiformats": "^13.0.1", 2262 + "protons-runtime": "^5.5.0", 2263 + "uint8arraylist": "^2.4.8", 2264 + "uint8arrays": "^5.0.1" 2265 + }, 2266 + "engines": { 2267 + "npm": ">=8.7.0" 2268 + } 2269 + }, 2270 + "node_modules/@libp2p/gossipsub/node_modules/multiformats": { 2271 + "version": "13.4.2", 2272 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", 2273 + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", 2274 + "license": "Apache-2.0 OR MIT" 2275 + }, 2276 + "node_modules/@libp2p/gossipsub/node_modules/uint8arrays": { 2277 + "version": "5.1.0", 2278 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", 2279 + "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", 2280 + "license": "Apache-2.0 OR MIT", 2281 + "dependencies": { 2282 + "multiformats": "^13.0.0" 2283 + } 2284 + }, 2253 2285 "node_modules/@libp2p/http": { 2254 2286 "version": "2.0.1", 2255 2287 "resolved": "https://registry.npmjs.org/@libp2p/http/-/http-2.0.1.tgz", ··· 3073 3105 "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", 3074 3106 "license": "Apache-2.0 OR MIT" 3075 3107 }, 3108 + "node_modules/@multiformats/dns/node_modules/p-queue": { 3109 + "version": "9.1.0", 3110 + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", 3111 + "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", 3112 + "license": "MIT", 3113 + "dependencies": { 3114 + "eventemitter3": "^5.0.1", 3115 + "p-timeout": "^7.0.0" 3116 + }, 3117 + "engines": { 3118 + "node": ">=20" 3119 + }, 3120 + "funding": { 3121 + "url": "https://github.com/sponsors/sindresorhus" 3122 + } 3123 + }, 3076 3124 "node_modules/@multiformats/dns/node_modules/uint8arrays": { 3077 3125 "version": "5.1.0", 3078 3126 "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", ··· 3388 3436 } 3389 3437 }, 3390 3438 "node_modules/@react-native/assets-registry": { 3391 - "version": "0.83.1", 3392 - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.1.tgz", 3393 - "integrity": "sha512-AT7/T6UwQqO39bt/4UL5EXvidmrddXrt0yJa7ENXndAv+8yBzMsZn6fyiax6+ERMt9GLzAECikv3lj22cn2wJA==", 3439 + "version": "0.84.0", 3440 + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.84.0.tgz", 3441 + "integrity": "sha512-YiU9h1IN0pvvZsHbd03MaD7mE2q+ySaKMlE9tWK+3iiwtbEaMQOsMUuSJ1er2LU6ERMWfhfvCYgWpKRGOMeN8A==", 3394 3442 "license": "MIT", 3395 3443 "peer": true, 3396 3444 "engines": { ··· 3398 3446 } 3399 3447 }, 3400 3448 "node_modules/@react-native/codegen": { 3401 - "version": "0.83.1", 3402 - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.83.1.tgz", 3403 - "integrity": "sha512-FpRxenonwH+c2a5X5DZMKUD7sCudHxB3eSQPgV9R+uxd28QWslyAWrpnJM/Az96AEksHnymDzEmzq2HLX5nb+g==", 3449 + "version": "0.84.0", 3450 + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.84.0.tgz", 3451 + "integrity": "sha512-TcTAO58JigCw9onYTrbE2yK2js5YNgqbmnpYyq9oXz2mofbX7JcK53kIi7fhqyJhie8RkY+X85zSOTWNs6S3CA==", 3404 3452 "license": "MIT", 3405 3453 "peer": true, 3406 3454 "dependencies": { 3407 3455 "@babel/core": "^7.25.2", 3408 3456 "@babel/parser": "^7.25.3", 3409 - "glob": "^7.1.1", 3410 3457 "hermes-parser": "0.32.0", 3411 3458 "invariant": "^2.2.4", 3412 3459 "nullthrows": "^1.1.1", 3460 + "tinyglobby": "^0.2.15", 3413 3461 "yargs": "^17.6.2" 3414 3462 }, 3415 3463 "engines": { ··· 3420 3468 } 3421 3469 }, 3422 3470 "node_modules/@react-native/community-cli-plugin": { 3423 - "version": "0.83.1", 3424 - "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.83.1.tgz", 3425 - "integrity": "sha512-FqR1ftydr08PYlRbrDF06eRiiiGOK/hNmz5husv19sK6iN5nHj1SMaCIVjkH/a5vryxEddyFhU6PzO/uf4kOHg==", 3471 + "version": "0.84.0", 3472 + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.84.0.tgz", 3473 + "integrity": "sha512-uYoLBHnAzod4E5dA5rPPQeny2A5RD0PiIJQ4r+2F7cvA+5bZ8+znxw4TdaSiEk8uhN+clffI4d2bl9V4+xEK+Q==", 3426 3474 "license": "MIT", 3427 3475 "peer": true, 3428 3476 "dependencies": { 3429 - "@react-native/dev-middleware": "0.83.1", 3477 + "@react-native/dev-middleware": "0.84.0", 3430 3478 "debug": "^4.4.0", 3431 3479 "invariant": "^2.2.4", 3432 3480 "metro": "^0.83.3", ··· 3451 3499 } 3452 3500 }, 3453 3501 "node_modules/@react-native/debugger-frontend": { 3454 - "version": "0.83.1", 3455 - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.83.1.tgz", 3456 - "integrity": "sha512-01Rn3goubFvPjHXONooLmsW0FLxJDKIUJNOlOS0cPtmmTIx9YIjxhe/DxwHXGk7OnULd7yl3aYy7WlBsEd5Xmg==", 3502 + "version": "0.84.0", 3503 + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.84.0.tgz", 3504 + "integrity": "sha512-n7JKYVDCbA2aj8/5/OD1IK7nuiAYj5l/Z6yhGf7GG4EGaeQdthqdb0LZbseaRPyZK/7tLfdnLdqlqdTQC6/UTQ==", 3457 3505 "license": "BSD-3-Clause", 3458 3506 "peer": true, 3459 3507 "engines": { ··· 3461 3509 } 3462 3510 }, 3463 3511 "node_modules/@react-native/debugger-shell": { 3464 - "version": "0.83.1", 3465 - "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.83.1.tgz", 3466 - "integrity": "sha512-d+0w446Hxth5OP/cBHSSxOEpbj13p2zToUy6e5e3tTERNJ8ueGlW7iGwGTrSymNDgXXFjErX+dY4P4/3WokPIQ==", 3512 + "version": "0.84.0", 3513 + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.84.0.tgz", 3514 + "integrity": "sha512-5t/NvQLYk/d0kWlGOMNobkjfimqBc+/LYRmSOkgKm+pyOhxjygCLSnRjAUkeRALSZ8h6MKGTz1Wc4pbmJr7T0Q==", 3467 3515 "license": "MIT", 3468 3516 "peer": true, 3469 3517 "dependencies": { 3470 3518 "cross-spawn": "^7.0.6", 3519 + "debug": "^4.4.0", 3471 3520 "fb-dotslash": "0.5.8" 3472 3521 }, 3473 3522 "engines": { ··· 3475 3524 } 3476 3525 }, 3477 3526 "node_modules/@react-native/dev-middleware": { 3478 - "version": "0.83.1", 3479 - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.83.1.tgz", 3480 - "integrity": "sha512-QJaSfNRzj3Lp7MmlCRgSBlt1XZ38xaBNXypXAp/3H3OdFifnTZOeYOpFmcpjcXYnDqkxetuwZg8VL65SQhB8dg==", 3527 + "version": "0.84.0", 3528 + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.84.0.tgz", 3529 + "integrity": "sha512-c0o7YW39AUI1FSLV/TFSszr87kQGmaePAQK0ygIRnwZ2fAGDnQ5Iu/tk3u9O5lVH6nTjfAwTKJ3El9YeEWDeEQ==", 3481 3530 "license": "MIT", 3482 3531 "peer": true, 3483 3532 "dependencies": { 3484 3533 "@isaacs/ttlcache": "^1.4.1", 3485 - "@react-native/debugger-frontend": "0.83.1", 3486 - "@react-native/debugger-shell": "0.83.1", 3534 + "@react-native/debugger-frontend": "0.84.0", 3535 + "@react-native/debugger-shell": "0.84.0", 3487 3536 "chrome-launcher": "^0.15.2", 3488 3537 "chromium-edge-launcher": "^0.2.0", 3489 3538 "connect": "^3.6.5", ··· 3521 3570 } 3522 3571 }, 3523 3572 "node_modules/@react-native/gradle-plugin": { 3524 - "version": "0.83.1", 3525 - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.83.1.tgz", 3526 - "integrity": "sha512-6ESDnwevp1CdvvxHNgXluil5OkqbjkJAkVy7SlpFsMGmVhrSxNAgD09SSRxMNdKsnLtzIvMsFCzyHLsU/S4PtQ==", 3573 + "version": "0.84.0", 3574 + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.84.0.tgz", 3575 + "integrity": "sha512-j8g/I4Z+SAdh2NXOVng4rmfYgPoeJBZwAKoGPpSe/wB/9XDLh9IRGUTg8dGS5BWUy2471xBUoGZPwHb6QMJmVw==", 3527 3576 "license": "MIT", 3528 3577 "peer": true, 3529 3578 "engines": { ··· 3531 3580 } 3532 3581 }, 3533 3582 "node_modules/@react-native/js-polyfills": { 3534 - "version": "0.83.1", 3535 - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.83.1.tgz", 3536 - "integrity": "sha512-qgPpdWn/c5laA+3WoJ6Fak8uOm7CG50nBsLlPsF8kbT7rUHIVB9WaP6+GPsoKV/H15koW7jKuLRoNVT7c3Ht3w==", 3583 + "version": "0.84.0", 3584 + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.84.0.tgz", 3585 + "integrity": "sha512-xaxmzYWLgHH+2uAZQ0owEkDE58hOTWmuBKD/Gl+cDFD3mFfSK4lZpin/3hiXtE5LB4BwgqICsPN07zCAqx6Fpg==", 3537 3586 "license": "MIT", 3538 3587 "peer": true, 3539 3588 "engines": { ··· 3541 3590 } 3542 3591 }, 3543 3592 "node_modules/@react-native/normalize-colors": { 3544 - "version": "0.83.1", 3545 - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.1.tgz", 3546 - "integrity": "sha512-84feABbmeWo1kg81726UOlMKAhcQyFXYz2SjRKYkS78QmfhVDhJ2o/ps1VjhFfBz0i/scDwT1XNv9GwmRIghkg==", 3593 + "version": "0.84.0", 3594 + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.84.0.tgz", 3595 + "integrity": "sha512-7JgZyWtQ9Sz4qZvCTsURUtuv8/niEZ/iCorp7eExc3GgpBWNazPumieiUoWPdgRKofU0Bqpr2/dJevEn2hrlwA==", 3547 3596 "license": "MIT", 3548 3597 "peer": true 3549 3598 }, 3550 3599 "node_modules/@react-native/virtualized-lists": { 3551 - "version": "0.83.1", 3552 - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.83.1.tgz", 3553 - "integrity": "sha512-MdmoAbQUTOdicCocm5XAFDJWsswxk7hxa6ALnm6Y88p01HFML0W593hAn6qOt9q6IM1KbAcebtH6oOd4gcQy8w==", 3600 + "version": "0.84.0", 3601 + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.84.0.tgz", 3602 + "integrity": "sha512-ugwSj0Gb4MYrcm8uQrQw8qHPx5RKGDLuZRAP/AuwneFizHx8YCLBEFbOYRGWgxHBRtkJ70D1o+jpIx3CK3p5lw==", 3554 3603 "license": "MIT", 3555 3604 "peer": true, 3556 3605 "dependencies": { ··· 4423 4472 "node": ">= 8" 4424 4473 } 4425 4474 }, 4426 - "node_modules/anymatch/node_modules/picomatch": { 4427 - "version": "2.3.1", 4428 - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 4429 - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 4430 - "license": "MIT", 4431 - "peer": true, 4432 - "engines": { 4433 - "node": ">=8.6" 4434 - }, 4435 - "funding": { 4436 - "url": "https://github.com/sponsors/jonschlinkert" 4437 - } 4438 - }, 4439 4475 "node_modules/argparse": { 4440 4476 "version": "1.0.10", 4441 4477 "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", ··· 5311 5347 "node": ">=0.4.0" 5312 5348 } 5313 5349 }, 5350 + "node_modules/denque": { 5351 + "version": "2.1.0", 5352 + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", 5353 + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", 5354 + "license": "Apache-2.0", 5355 + "engines": { 5356 + "node": ">=0.10" 5357 + } 5358 + }, 5314 5359 "node_modules/depd": { 5315 5360 "version": "2.0.0", 5316 5361 "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", ··· 5707 5752 "bser": "2.1.1" 5708 5753 } 5709 5754 }, 5710 - "node_modules/fdir": { 5711 - "version": "6.5.0", 5712 - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 5713 - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 5714 - "dev": true, 5715 - "license": "MIT", 5716 - "engines": { 5717 - "node": ">=12.0.0" 5718 - }, 5719 - "peerDependencies": { 5720 - "picomatch": "^3 || ^4" 5721 - }, 5722 - "peerDependenciesMeta": { 5723 - "picomatch": { 5724 - "optional": true 5725 - } 5726 - } 5727 - }, 5728 5755 "node_modules/file-uri-to-path": { 5729 5756 "version": "1.0.0", 5730 5757 "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", ··· 6164 6191 "license": "Apache-2.0 OR MIT" 6165 6192 }, 6166 6193 "node_modules/hermes-compiler": { 6167 - "version": "0.14.0", 6168 - "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-0.14.0.tgz", 6169 - "integrity": "sha512-clxa193o+GYYwykWVFfpHduCATz8fR5jvU7ngXpfKHj+E9hr9vjLNtdLSEe8MUbObvVexV3wcyxQ00xTPIrB1Q==", 6194 + "version": "250829098.0.7", 6195 + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.7.tgz", 6196 + "integrity": "sha512-8QOmg1VjAWv8poFVslJDY8qkvjTy/UiO3R/hyGoC0IAchLzBdS9/TmAvI9cN1F3yLTEjimAIQQtUslpBMPXVVg==", 6170 6197 "license": "MIT", 6171 6198 "peer": true 6172 6199 }, ··· 6518 6545 "node": ">=0.12.0" 6519 6546 } 6520 6547 }, 6521 - "node_modules/is-plain-obj": { 6522 - "version": "2.1.0", 6523 - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", 6524 - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", 6525 - "license": "MIT", 6526 - "engines": { 6527 - "node": ">=8" 6528 - } 6529 - }, 6530 6548 "node_modules/is-regexp": { 6531 6549 "version": "3.1.0", 6532 6550 "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", ··· 7014 7032 "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 7015 7033 } 7016 7034 }, 7017 - "node_modules/jest-util/node_modules/picomatch": { 7018 - "version": "2.3.1", 7019 - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 7020 - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 7021 - "license": "MIT", 7022 - "peer": true, 7023 - "engines": { 7024 - "node": ">=8.6" 7025 - }, 7026 - "funding": { 7027 - "url": "https://github.com/sponsors/jonschlinkert" 7028 - } 7029 - }, 7030 7035 "node_modules/jest-validate": { 7031 7036 "version": "29.7.0", 7032 7037 "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", ··· 7100 7105 } 7101 7106 }, 7102 7107 "node_modules/js-tokens": { 7103 - "version": "9.0.1", 7104 - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", 7105 - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", 7106 - "dev": true, 7107 - "license": "MIT" 7108 + "version": "4.0.0", 7109 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 7110 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 7111 + "license": "MIT", 7112 + "peer": true 7108 7113 }, 7109 7114 "node_modules/js-yaml": { 7110 7115 "version": "3.14.2", ··· 7274 7279 "loose-envify": "cli.js" 7275 7280 } 7276 7281 }, 7277 - "node_modules/loose-envify/node_modules/js-tokens": { 7278 - "version": "4.0.0", 7279 - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 7280 - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 7281 - "license": "MIT", 7282 - "peer": true 7283 - }, 7284 7282 "node_modules/loupe": { 7285 7283 "version": "3.2.1", 7286 7284 "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", ··· 7357 7355 }, 7358 7356 "engines": { 7359 7357 "node": ">=10" 7358 + } 7359 + }, 7360 + "node_modules/merge-options/node_modules/is-plain-obj": { 7361 + "version": "2.1.0", 7362 + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", 7363 + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", 7364 + "license": "MIT", 7365 + "engines": { 7366 + "node": ">=8" 7360 7367 } 7361 7368 }, 7362 7369 "node_modules/merge-stream": { ··· 7698 7705 }, 7699 7706 "engines": { 7700 7707 "node": ">=8.6" 7701 - } 7702 - }, 7703 - "node_modules/micromatch/node_modules/picomatch": { 7704 - "version": "2.3.1", 7705 - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 7706 - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 7707 - "license": "MIT", 7708 - "engines": { 7709 - "node": ">=8.6" 7710 - }, 7711 - "funding": { 7712 - "url": "https://github.com/sponsors/jonschlinkert" 7713 7708 } 7714 7709 }, 7715 7710 "node_modules/mime": { ··· 7836 7831 "license": "(Apache-2.0 AND MIT)" 7837 7832 }, 7838 7833 "node_modules/nanoid": { 7839 - "version": "3.3.11", 7840 - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 7841 - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 7842 - "dev": true, 7834 + "version": "5.1.6", 7835 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", 7836 + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", 7843 7837 "funding": [ 7844 7838 { 7845 7839 "type": "github", ··· 7848 7842 ], 7849 7843 "license": "MIT", 7850 7844 "bin": { 7851 - "nanoid": "bin/nanoid.cjs" 7845 + "nanoid": "bin/nanoid.js" 7852 7846 }, 7853 7847 "engines": { 7854 - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 7848 + "node": "^18 || >=20" 7855 7849 } 7856 7850 }, 7857 7851 "node_modules/napi-build-utils": { ··· 8070 8064 }, 8071 8065 "engines": { 8072 8066 "node": ">=8" 8073 - } 8074 - }, 8075 - "node_modules/p-queue": { 8076 - "version": "9.1.0", 8077 - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", 8078 - "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", 8079 - "license": "MIT", 8080 - "dependencies": { 8081 - "eventemitter3": "^5.0.1", 8082 - "p-timeout": "^7.0.0" 8083 - }, 8084 - "engines": { 8085 - "node": ">=20" 8086 - }, 8087 - "funding": { 8088 - "url": "https://github.com/sponsors/sindresorhus" 8089 8067 } 8090 8068 }, 8091 8069 "node_modules/p-retry": { ··· 8201 8179 "license": "ISC" 8202 8180 }, 8203 8181 "node_modules/picomatch": { 8204 - "version": "4.0.3", 8205 - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 8206 - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 8207 - "dev": true, 8182 + "version": "2.3.1", 8183 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 8184 + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 8208 8185 "license": "MIT", 8209 8186 "engines": { 8210 - "node": ">=12" 8187 + "node": ">=8.6" 8211 8188 }, 8212 8189 "funding": { 8213 8190 "url": "https://github.com/sponsors/jonschlinkert" ··· 8290 8267 "node": "^10 || ^12 || >=14" 8291 8268 } 8292 8269 }, 8270 + "node_modules/postcss/node_modules/nanoid": { 8271 + "version": "3.3.11", 8272 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 8273 + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 8274 + "dev": true, 8275 + "funding": [ 8276 + { 8277 + "type": "github", 8278 + "url": "https://github.com/sponsors/ai" 8279 + } 8280 + ], 8281 + "license": "MIT", 8282 + "bin": { 8283 + "nanoid": "bin/nanoid.cjs" 8284 + }, 8285 + "engines": { 8286 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 8287 + } 8288 + }, 8293 8289 "node_modules/prebuild-install": { 8294 8290 "version": "7.1.3", 8295 8291 "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", ··· 8574 8570 "peer": true 8575 8571 }, 8576 8572 "node_modules/react-native": { 8577 - "version": "0.83.1", 8578 - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.83.1.tgz", 8579 - "integrity": "sha512-mL1q5HPq5cWseVhWRLl+Fwvi5z1UO+3vGOpjr+sHFwcUletPRZ5Kv+d0tUfqHmvi73/53NjlQqX1Pyn4GguUfA==", 8573 + "version": "0.84.0", 8574 + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.84.0.tgz", 8575 + "integrity": "sha512-CcBfucLDHz8MAjQx9kFXasYtpcn8zP1YapUgGtAy0psRZTLShwF9yeh5+ErSgEK2gXV1CCSz7hqCZqx1eMyBLA==", 8580 8576 "license": "MIT", 8581 8577 "peer": true, 8582 8578 "dependencies": { 8583 8579 "@jest/create-cache-key-function": "^29.7.0", 8584 - "@react-native/assets-registry": "0.83.1", 8585 - "@react-native/codegen": "0.83.1", 8586 - "@react-native/community-cli-plugin": "0.83.1", 8587 - "@react-native/gradle-plugin": "0.83.1", 8588 - "@react-native/js-polyfills": "0.83.1", 8589 - "@react-native/normalize-colors": "0.83.1", 8590 - "@react-native/virtualized-lists": "0.83.1", 8580 + "@react-native/assets-registry": "0.84.0", 8581 + "@react-native/codegen": "0.84.0", 8582 + "@react-native/community-cli-plugin": "0.84.0", 8583 + "@react-native/gradle-plugin": "0.84.0", 8584 + "@react-native/js-polyfills": "0.84.0", 8585 + "@react-native/normalize-colors": "0.84.0", 8586 + "@react-native/virtualized-lists": "0.84.0", 8591 8587 "abort-controller": "^3.0.0", 8592 8588 "anser": "^1.4.9", 8593 8589 "ansi-regex": "^5.0.0", ··· 8596 8592 "base64-js": "^1.5.1", 8597 8593 "commander": "^12.0.0", 8598 8594 "flow-enums-runtime": "^0.0.6", 8599 - "glob": "^7.1.1", 8600 - "hermes-compiler": "0.14.0", 8595 + "hermes-compiler": "250829098.0.7", 8601 8596 "invariant": "^2.2.4", 8602 8597 "jest-environment-node": "^29.7.0", 8603 8598 "memoize-one": "^5.0.0", ··· 8612 8607 "scheduler": "0.27.0", 8613 8608 "semver": "^7.1.3", 8614 8609 "stacktrace-parser": "^0.1.10", 8610 + "tinyglobby": "^0.2.15", 8615 8611 "whatwg-fetch": "^3.0.0", 8616 8612 "ws": "^7.5.10", 8617 8613 "yargs": "^17.6.2" ··· 8624 8620 }, 8625 8621 "peerDependencies": { 8626 8622 "@types/react": "^19.1.1", 8627 - "react": "^19.2.0" 8623 + "react": "^19.2.3" 8628 8624 }, 8629 8625 "peerDependenciesMeta": { 8630 8626 "@types/react": { ··· 9375 9371 "url": "https://github.com/sponsors/antfu" 9376 9372 } 9377 9373 }, 9374 + "node_modules/strip-literal/node_modules/js-tokens": { 9375 + "version": "9.0.1", 9376 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", 9377 + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", 9378 + "dev": true, 9379 + "license": "MIT" 9380 + }, 9378 9381 "node_modules/super-regex": { 9379 9382 "version": "0.2.0", 9380 9383 "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-0.2.0.tgz", ··· 9552 9555 "version": "0.2.15", 9553 9556 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", 9554 9557 "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", 9555 - "dev": true, 9556 9558 "license": "MIT", 9557 9559 "dependencies": { 9558 9560 "fdir": "^6.5.0", ··· 9565 9567 "url": "https://github.com/sponsors/SuperchupuDev" 9566 9568 } 9567 9569 }, 9570 + "node_modules/tinyglobby/node_modules/fdir": { 9571 + "version": "6.5.0", 9572 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 9573 + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 9574 + "license": "MIT", 9575 + "engines": { 9576 + "node": ">=12.0.0" 9577 + }, 9578 + "peerDependencies": { 9579 + "picomatch": "^3 || ^4" 9580 + }, 9581 + "peerDependenciesMeta": { 9582 + "picomatch": { 9583 + "optional": true 9584 + } 9585 + } 9586 + }, 9587 + "node_modules/tinyglobby/node_modules/picomatch": { 9588 + "version": "4.0.3", 9589 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 9590 + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 9591 + "license": "MIT", 9592 + "engines": { 9593 + "node": ">=12" 9594 + }, 9595 + "funding": { 9596 + "url": "https://github.com/sponsors/jonschlinkert" 9597 + } 9598 + }, 9568 9599 "node_modules/tinypool": { 9569 9600 "version": "1.1.1", 9570 9601 "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", ··· 9987 10018 "url": "https://opencollective.com/vitest" 9988 10019 } 9989 10020 }, 10021 + "node_modules/vite/node_modules/fdir": { 10022 + "version": "6.5.0", 10023 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 10024 + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 10025 + "dev": true, 10026 + "license": "MIT", 10027 + "engines": { 10028 + "node": ">=12.0.0" 10029 + }, 10030 + "peerDependencies": { 10031 + "picomatch": "^3 || ^4" 10032 + }, 10033 + "peerDependenciesMeta": { 10034 + "picomatch": { 10035 + "optional": true 10036 + } 10037 + } 10038 + }, 10039 + "node_modules/vite/node_modules/picomatch": { 10040 + "version": "4.0.3", 10041 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 10042 + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 10043 + "dev": true, 10044 + "license": "MIT", 10045 + "engines": { 10046 + "node": ">=12" 10047 + }, 10048 + "funding": { 10049 + "url": "https://github.com/sponsors/jonschlinkert" 10050 + } 10051 + }, 9990 10052 "node_modules/vitest": { 9991 10053 "version": "3.2.4", 9992 10054 "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", ··· 10058 10120 "jsdom": { 10059 10121 "optional": true 10060 10122 } 10123 + } 10124 + }, 10125 + "node_modules/vitest/node_modules/picomatch": { 10126 + "version": "4.0.3", 10127 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 10128 + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 10129 + "dev": true, 10130 + "license": "MIT", 10131 + "engines": { 10132 + "node": ">=12" 10133 + }, 10134 + "funding": { 10135 + "url": "https://github.com/sponsors/jonschlinkert" 10061 10136 } 10062 10137 }, 10063 10138 "node_modules/vlq": {
+1
package.json
··· 26 26 "@atproto/lex-json": "^0.0.11", 27 27 "@atproto/repo": "^0.8.12", 28 28 "@hono/node-server": "^1.13.8", 29 + "@libp2p/gossipsub": "^15.0.12", 29 30 "bcryptjs": "^3.0.3", 30 31 "better-sqlite3": "^11.8.1", 31 32 "blockstore-fs": "^3.0.2",
+24
src/ipfs.test.ts
··· 193 193 ).resolves.toBeUndefined(); 194 194 }); 195 195 }); 196 + 197 + describe("gossipsub no-ops without networking", () => { 198 + it("onCommitNotification does not throw", async () => { 199 + await service.start(); 200 + expect(() => service.onCommitNotification(() => {})).not.toThrow(); 201 + }); 202 + 203 + it("subscribeCommitTopics does not throw", async () => { 204 + await service.start(); 205 + expect(() => service.subscribeCommitTopics(["did:plc:test"])).not.toThrow(); 206 + }); 207 + 208 + it("unsubscribeCommitTopics does not throw", async () => { 209 + await service.start(); 210 + expect(() => service.unsubscribeCommitTopics(["did:plc:test"])).not.toThrow(); 211 + }); 212 + 213 + it("publishCommitNotification resolves without error", async () => { 214 + await service.start(); 215 + await expect( 216 + service.publishCommitNotification("did:plc:test", "bafytest", "rev1"), 217 + ).resolves.toBeUndefined(); 218 + }); 219 + }); 196 220 }); 197 221 198 222 // ============================================
+147 -10
src/ipfs.ts
··· 3 3 import { FsDatastore } from "datastore-fs"; 4 4 import type { Helia } from "@helia/interface"; 5 5 import type { BlockMap } from "@atproto/repo"; 6 + import { encode as cborEncode, decode as cborDecode } from "./cbor-compat.js"; 6 7 7 8 /** 8 9 * Pure storage: put, get, has blocks by CID string. ··· 16 17 } 17 18 18 19 /** 19 - * P2P networking: content routing, peer identity, connectivity. 20 + * Lightweight gossipsub notification for a repo commit. 21 + * Contains only metadata — actual blocks are fetched via existing sync mechanisms. 22 + */ 23 + export interface CommitNotification { 24 + did: string; 25 + commit: string; 26 + rev: string; 27 + time: string; 28 + peer: string; 29 + } 30 + 31 + export type CommitNotificationHandler = (notification: CommitNotification) => void | Promise<void>; 32 + 33 + export const COMMIT_TOPIC_PREFIX = "/p2pds/commits/1/"; 34 + 35 + export function commitTopic(did: string): string { 36 + return `${COMMIT_TOPIC_PREFIX}${did}`; 37 + } 38 + 39 + /** 40 + * P2P networking: content routing, peer identity, connectivity, gossipsub. 20 41 * Separated from storage so transports can be swapped independently. 21 42 */ 22 43 export interface NetworkService { ··· 26 47 commitCid: string, 27 48 rev: string, 28 49 ): Promise<void>; 50 + onCommitNotification(handler: CommitNotificationHandler): void; 51 + subscribeCommitTopics(dids: string[]): void; 52 + unsubscribeCommitTopics(dids: string[]): void; 29 53 getPeerId(): string | null; 30 54 getMultiaddrs(): string[]; 31 55 getConnectionCount(): number; ··· 41 65 * IpfsService encapsulates all Helia/IPFS functionality. 42 66 * 43 67 * Implements both BlockStore (storage) and NetworkService (P2P networking). 44 - * When networking is enabled, a full Helia node is created (libp2p + bitswap + DHT). 68 + * When networking is enabled, a full Helia node is created (libp2p + bitswap + DHT + gossipsub). 45 69 * When networking is disabled, only a local FsBlockstore is used (for testing). 46 70 * 47 71 * All methods no-op gracefully if the service hasn't started yet. ··· 52 76 private blockstore: FsBlockstore | null = null; 53 77 private config: IpfsConfig; 54 78 private running = false; 79 + private commitHandlers: CommitNotificationHandler[] = []; 80 + private subscribedTopics: Set<string> = new Set(); 55 81 56 82 constructor(config: IpfsConfig) { 57 83 this.config = config; ··· 61 87 this.blockstore = new FsBlockstore(this.config.blocksPath); 62 88 63 89 if (this.config.networking) { 64 - const { createHelia } = await import("helia"); 90 + const { createHelia, libp2pDefaults } = await import("helia"); 91 + const { gossipsub } = await import("@libp2p/gossipsub"); 65 92 const datastore = new FsDatastore(this.config.datastorePath); 93 + 94 + const libp2pConfig = libp2pDefaults(); 95 + libp2pConfig.services.pubsub = gossipsub({ 96 + emitSelf: false, 97 + allowPublishToZeroTopicPeers: true, 98 + }); 99 + 66 100 this.helia = await createHelia({ 101 + libp2p: libp2pConfig, 67 102 blockstore: this.blockstore, 68 103 datastore, 69 104 }); 105 + this.setupGossipsubHandler(); 70 106 } else { 71 107 await this.blockstore.open(); 72 108 } ··· 83 119 } 84 120 this.blockstore = null; 85 121 this.running = false; 122 + this.subscribedTopics.clear(); 86 123 } 87 124 88 125 async putBlock(cidStr: string, bytes: Uint8Array): Promise<void> { ··· 163 200 } 164 201 165 202 /** 166 - * Stub for future pubsub integration. 167 - * Will publish commit notifications to /atproto/repos/{did} topic 168 - * when gossipsub is configured with peers. 203 + * Publish a commit notification via gossipsub. 204 + * CBOR-encodes { did, commit, rev, time, peer } and publishes to the DID's topic. 205 + * No-op when networking is disabled. 169 206 */ 170 207 async publishCommitNotification( 171 - _did: string, 172 - _commitCid: string, 173 - _rev: string, 208 + did: string, 209 + commitCid: string, 210 + rev: string, 174 211 ): Promise<void> { 175 - // Future: publish CBOR message { did, commit, rev, time, peer } via gossipsub 212 + if (!this.helia) return; 213 + const pubsub = (this.helia.libp2p.services as Record<string, unknown>).pubsub as 214 + { publish(topic: string, data: Uint8Array): Promise<unknown> } | undefined; 215 + if (!pubsub) return; 216 + 217 + const notification: CommitNotification = { 218 + did, 219 + commit: commitCid, 220 + rev, 221 + time: new Date().toISOString(), 222 + peer: this.helia.libp2p.peerId.toString(), 223 + }; 224 + 225 + const data = cborEncode(notification); 226 + await pubsub.publish(commitTopic(did), data); 227 + } 228 + 229 + /** 230 + * Register a handler for incoming commit notifications. 231 + * Multiple handlers can be registered; all are called for each notification. 232 + */ 233 + onCommitNotification(handler: CommitNotificationHandler): void { 234 + this.commitHandlers.push(handler); 235 + } 236 + 237 + /** 238 + * Subscribe to gossipsub topics for the given DIDs. 239 + * No-op when networking is disabled. 240 + */ 241 + subscribeCommitTopics(dids: string[]): void { 242 + if (!this.helia) return; 243 + const pubsub = (this.helia.libp2p.services as Record<string, unknown>).pubsub as 244 + { subscribe(topic: string): void } | undefined; 245 + if (!pubsub) return; 246 + 247 + for (const did of dids) { 248 + const topic = commitTopic(did); 249 + if (!this.subscribedTopics.has(topic)) { 250 + pubsub.subscribe(topic); 251 + this.subscribedTopics.add(topic); 252 + } 253 + } 254 + } 255 + 256 + /** 257 + * Unsubscribe from gossipsub topics for the given DIDs. 258 + * No-op when networking is disabled. 259 + */ 260 + unsubscribeCommitTopics(dids: string[]): void { 261 + if (!this.helia) return; 262 + const pubsub = (this.helia.libp2p.services as Record<string, unknown>).pubsub as 263 + { unsubscribe(topic: string): void } | undefined; 264 + if (!pubsub) return; 265 + 266 + for (const did of dids) { 267 + const topic = commitTopic(did); 268 + if (this.subscribedTopics.has(topic)) { 269 + pubsub.unsubscribe(topic); 270 + this.subscribedTopics.delete(topic); 271 + } 272 + } 273 + } 274 + 275 + /** 276 + * Set up the gossipsub message handler. 277 + * Listens for "message" events, CBOR-decodes, and dispatches to all registered handlers. 278 + */ 279 + private setupGossipsubHandler(): void { 280 + if (!this.helia) return; 281 + const pubsub = (this.helia.libp2p.services as Record<string, unknown>).pubsub as 282 + { addEventListener(event: string, handler: (evt: unknown) => void): void } | undefined; 283 + if (!pubsub) return; 284 + 285 + pubsub.addEventListener("message", (evt: unknown) => { 286 + try { 287 + const detail = (evt as { detail: { topic: string; data: Uint8Array } }).detail; 288 + if (!detail.topic.startsWith(COMMIT_TOPIC_PREFIX)) return; 289 + 290 + const notification = cborDecode(detail.data) as CommitNotification; 291 + if ( 292 + typeof notification.did !== "string" || 293 + typeof notification.commit !== "string" || 294 + typeof notification.rev !== "string" 295 + ) { 296 + return; 297 + } 298 + 299 + for (const handler of this.commitHandlers) { 300 + try { 301 + const result = handler(notification); 302 + if (result && typeof (result as Promise<void>).catch === "function") { 303 + (result as Promise<void>).catch(() => {}); 304 + } 305 + } catch { 306 + // Individual handler errors don't affect other handlers 307 + } 308 + } 309 + } catch { 310 + // Malformed messages are silently dropped 311 + } 312 + }); 176 313 } 177 314 178 315 getMultiaddrs(): string[] {
+5 -5
src/replication/challenge-response/libp2p-transport.ts
··· 6 6 * peers to have public HTTP endpoints. 7 7 * 8 8 * Wire format (half-close request-response): 9 - * 1. Requester: send() challenge JSON bytes, then close() write end 10 - * 2. Responder: read all bytes, process, send() response JSON bytes, then close() 9 + * 1. Requester: send challenge JSON bytes, then close() (half-close write) 10 + * 2. Responder: read all bytes, process, send response JSON bytes, then close() 11 11 * 3. Requester: read all response bytes 12 12 */ 13 13 ··· 60 60 const stream = await this.libp2p.dialProtocol(ma, CHALLENGE_PROTOCOL); 61 61 62 62 try { 63 - // Send challenge JSON and close write end 63 + // Send challenge JSON and half-close write end 64 64 const challengeBytes = new TextEncoder().encode( 65 65 JSON.stringify(challenge), 66 66 ); 67 67 stream.send(challengeBytes); 68 - await stream.close(); 68 + await stream.close(); // flush + close write; stream remains readable 69 69 70 70 // Read response 71 71 const responseBytes = await collectStream( ··· 88 88 ): void { 89 89 this.handler = handler; 90 90 91 - this.libp2p.handle(CHALLENGE_PROTOCOL, async (stream: Stream) => { 91 + this.libp2p.handle(CHALLENGE_PROTOCOL, async (stream) => { 92 92 try { 93 93 // Read challenge bytes 94 94 const challengeBytes = await collectStream(
+491
src/replication/gossipsub-notifications.test.ts
··· 1 + /** 2 + * Gossipsub commit notification tests. 3 + * 4 + * Tests CBOR encoding/decoding, topic generation, E2E gossipsub 5 + * between two Helia nodes, and ReplicationManager integration. 6 + */ 7 + 8 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 9 + import { mkdtempSync, rmSync } from "node:fs"; 10 + import { tmpdir } from "node:os"; 11 + import { join } from "node:path"; 12 + import type { Helia } from "@helia/interface"; 13 + import { 14 + encode as cborEncode, 15 + decode as cborDecode, 16 + } from "../cbor-compat.js"; 17 + import { 18 + commitTopic, 19 + COMMIT_TOPIC_PREFIX, 20 + type CommitNotification, 21 + } from "../ipfs.js"; 22 + 23 + // ============================================ 24 + // Message encoding + topic tests 25 + // ============================================ 26 + 27 + describe("CommitNotification encoding", () => { 28 + it("CBOR encode/decode round-trip", () => { 29 + const notification: CommitNotification = { 30 + did: "did:plc:abc123", 31 + commit: "bafyreiabc", 32 + rev: "3jui7kd2xxxx2", 33 + time: "2024-01-01T00:00:00.000Z", 34 + peer: "12D3KooWTest", 35 + }; 36 + 37 + const encoded = cborEncode(notification); 38 + expect(encoded).toBeInstanceOf(Uint8Array); 39 + expect(encoded.length).toBeGreaterThan(0); 40 + 41 + const decoded = cborDecode(encoded) as CommitNotification; 42 + expect(decoded.did).toBe(notification.did); 43 + expect(decoded.commit).toBe(notification.commit); 44 + expect(decoded.rev).toBe(notification.rev); 45 + expect(decoded.time).toBe(notification.time); 46 + expect(decoded.peer).toBe(notification.peer); 47 + }); 48 + 49 + it("topic generation from DID", () => { 50 + const did = "did:plc:abc123"; 51 + const topic = commitTopic(did); 52 + expect(topic).toBe("/p2pds/commits/1/did:plc:abc123"); 53 + expect(topic.startsWith(COMMIT_TOPIC_PREFIX)).toBe(true); 54 + }); 55 + 56 + it("different DIDs produce different topics", () => { 57 + const topic1 = commitTopic("did:plc:aaa"); 58 + const topic2 = commitTopic("did:plc:bbb"); 59 + expect(topic1).not.toBe(topic2); 60 + }); 61 + }); 62 + 63 + // ============================================ 64 + // E2E gossipsub test (two Helia nodes) 65 + // ============================================ 66 + 67 + describe("E2E gossipsub: two Helia nodes", () => { 68 + let tmpDir: string; 69 + let nodeA: Helia | null = null; 70 + let nodeB: Helia | null = null; 71 + 72 + beforeEach(() => { 73 + tmpDir = mkdtempSync(join(tmpdir(), "gossipsub-e2e-test-")); 74 + }); 75 + 76 + afterEach(async () => { 77 + const stops: Promise<void>[] = []; 78 + if (nodeA) stops.push(nodeA.stop().catch(() => {})); 79 + if (nodeB) stops.push(nodeB.stop().catch(() => {})); 80 + await Promise.all(stops); 81 + nodeA = null; 82 + nodeB = null; 83 + rmSync(tmpDir, { recursive: true, force: true }); 84 + }); 85 + 86 + /** 87 + * Create a minimal Helia node with TCP + gossipsub for testing. 88 + * Strips out all discovery, relay, etc. — just TCP + noise + yamux + identify + gossipsub. 89 + */ 90 + async function createGossipsubTestNode( 91 + blocksPath: string, 92 + datastorePath: string, 93 + ): Promise<Helia> { 94 + const { createHelia } = await import("helia"); 95 + const { noise } = await import("@chainsafe/libp2p-noise"); 96 + const { yamux } = await import("@chainsafe/libp2p-yamux"); 97 + const { tcp } = await import("@libp2p/tcp"); 98 + const { identify } = await import("@libp2p/identify"); 99 + const { gossipsub } = await import("@libp2p/gossipsub"); 100 + const { createLibp2p } = await import("libp2p"); 101 + const { FsBlockstore } = await import("blockstore-fs"); 102 + const { FsDatastore } = await import("datastore-fs"); 103 + 104 + const blockstore = new FsBlockstore(blocksPath); 105 + const datastore = new FsDatastore(datastorePath); 106 + 107 + const libp2p = await createLibp2p({ 108 + addresses: { 109 + listen: ["/ip4/127.0.0.1/tcp/0"], 110 + }, 111 + transports: [tcp()], 112 + connectionEncrypters: [noise()], 113 + streamMuxers: [yamux()], 114 + services: { 115 + identify: identify(), 116 + pubsub: gossipsub({ 117 + emitSelf: false, 118 + allowPublishToZeroTopicPeers: true, 119 + }), 120 + }, 121 + }); 122 + 123 + return createHelia({ 124 + libp2p, 125 + blockstore, 126 + datastore, 127 + }); 128 + } 129 + 130 + it("notification published by one node is received by connected peer", { timeout: 60_000 }, async () => { 131 + nodeA = await createGossipsubTestNode( 132 + join(tmpDir, "a-blocks"), 133 + join(tmpDir, "a-datastore"), 134 + ); 135 + nodeB = await createGossipsubTestNode( 136 + join(tmpDir, "b-blocks"), 137 + join(tmpDir, "b-datastore"), 138 + ); 139 + 140 + // Connect the nodes 141 + const addrsA = nodeA.libp2p.getMultiaddrs(); 142 + expect(addrsA.length).toBeGreaterThan(0); 143 + await nodeB.libp2p.dial(addrsA[0]!); 144 + 145 + // Wait for connection 146 + await waitFor(() => 147 + nodeA!.libp2p.getConnections().length > 0 && 148 + nodeB!.libp2p.getConnections().length > 0, 149 + 5_000, 150 + ); 151 + 152 + const testDid = "did:plc:gossiptest123"; 153 + const topic = commitTopic(testDid); 154 + 155 + // Access pubsub service 156 + const pubsubA = (nodeA.libp2p.services as Record<string, unknown>).pubsub as { 157 + subscribe(topic: string): void; 158 + addEventListener(event: string, handler: (evt: unknown) => void): void; 159 + }; 160 + const pubsubB = (nodeB.libp2p.services as Record<string, unknown>).pubsub as { 161 + subscribe(topic: string): void; 162 + publish(topic: string, data: Uint8Array): Promise<unknown>; 163 + }; 164 + 165 + // Both nodes subscribe (needed for mesh formation) 166 + const received: CommitNotification[] = []; 167 + pubsubA.subscribe(topic); 168 + pubsubB.subscribe(topic); 169 + 170 + pubsubA.addEventListener("message", (evt: unknown) => { 171 + try { 172 + const detail = (evt as { detail: { topic: string; data: Uint8Array } }).detail; 173 + if (detail.topic === topic) { 174 + const notification = cborDecode(detail.data) as CommitNotification; 175 + received.push(notification); 176 + } 177 + } catch { 178 + // ignore decode errors in test 179 + } 180 + }); 181 + 182 + // Wait for gossipsub mesh to form, then publish repeatedly until received. 183 + // Gossipsub mesh formation requires multiple heartbeat cycles (~1s each). 184 + // We publish every 2s until the message gets through. 185 + const notification: CommitNotification = { 186 + did: testDid, 187 + commit: "bafyreiabc", 188 + rev: "3jui7kd2xxxx2", 189 + time: new Date().toISOString(), 190 + peer: nodeB.libp2p.peerId.toString(), 191 + }; 192 + const data = cborEncode(notification); 193 + 194 + await waitFor(async () => { 195 + if (received.length > 0) return true; 196 + await pubsubB.publish(topic, data).catch(() => {}); 197 + await new Promise((r) => setTimeout(r, 1000)); 198 + return received.length > 0; 199 + }, 30_000, 500); 200 + 201 + expect(received.length).toBe(1); 202 + expect(received[0]!.did).toBe(testDid); 203 + expect(received[0]!.commit).toBe("bafyreiabc"); 204 + expect(received[0]!.rev).toBe("3jui7kd2xxxx2"); 205 + expect(received[0]!.peer).toBe(nodeB.libp2p.peerId.toString()); 206 + expect(typeof received[0]!.time).toBe("string"); 207 + }); 208 + }); 209 + 210 + // ============================================ 211 + // ReplicationManager integration (mock NetworkService) 212 + // ============================================ 213 + 214 + describe("ReplicationManager gossipsub integration", () => { 215 + let tmpDir: string; 216 + 217 + beforeEach(() => { 218 + tmpDir = mkdtempSync(join(tmpdir(), "repl-gossipsub-test-")); 219 + }); 220 + 221 + afterEach(() => { 222 + rmSync(tmpDir, { recursive: true, force: true }); 223 + }); 224 + 225 + function createMockNetworkService() { 226 + const handlers: Array<(n: CommitNotification) => void | Promise<void>> = []; 227 + const subscribedDids: string[] = []; 228 + const publishedNotifications: Array<{ did: string; commitCid: string; rev: string }> = []; 229 + 230 + return { 231 + provideBlocks: vi.fn().mockResolvedValue(undefined), 232 + publishCommitNotification: vi.fn().mockImplementation( 233 + async (did: string, commitCid: string, rev: string) => { 234 + publishedNotifications.push({ did, commitCid, rev }); 235 + }, 236 + ), 237 + onCommitNotification: vi.fn().mockImplementation( 238 + (handler: (n: CommitNotification) => void | Promise<void>) => { 239 + handlers.push(handler); 240 + }, 241 + ), 242 + subscribeCommitTopics: vi.fn().mockImplementation( 243 + (dids: string[]) => { 244 + subscribedDids.push(...dids); 245 + }, 246 + ), 247 + unsubscribeCommitTopics: vi.fn(), 248 + getPeerId: vi.fn().mockReturnValue("12D3KooWMockPeer"), 249 + getMultiaddrs: vi.fn().mockReturnValue(["/ip4/127.0.0.1/tcp/4001"]), 250 + getConnectionCount: vi.fn().mockReturnValue(0), 251 + // Test helpers 252 + _handlers: handlers, 253 + _subscribedDids: subscribedDids, 254 + _publishedNotifications: publishedNotifications, 255 + _simulateNotification(notification: CommitNotification) { 256 + for (const handler of handlers) { 257 + handler(notification); 258 + } 259 + }, 260 + }; 261 + } 262 + 263 + function createMockBlockStore() { 264 + return { 265 + putBlock: vi.fn().mockResolvedValue(undefined), 266 + getBlock: vi.fn().mockResolvedValue(null), 267 + hasBlock: vi.fn().mockResolvedValue(false), 268 + putBlocks: vi.fn().mockResolvedValue(undefined), 269 + }; 270 + } 271 + 272 + it("init() subscribes to commit topics for tracked DIDs", async () => { 273 + const Database = (await import("better-sqlite3")).default; 274 + const { ReplicationManager } = await import("./replication-manager.js"); 275 + const { DidResolver } = await import("../did-resolver.js"); 276 + 277 + const db = new Database(join(tmpDir, "test.db")); 278 + const config = { 279 + DID: "did:plc:local", 280 + HANDLE: "test.example.com", 281 + PDS_HOSTNAME: "test.example.com", 282 + AUTH_TOKEN: "test", 283 + SIGNING_KEY: "0000000000000000000000000000000000000000000000000000000000000001", 284 + SIGNING_KEY_PUBLIC: "zQ3shP2mWsZYWgvZM9GJ3EvMfRXQJwuTh6BdXLvJB9gFhT3Lr", 285 + JWT_SECRET: "test", 286 + PASSWORD_HASH: "$2a$10$test", 287 + DATA_DIR: tmpDir, 288 + PORT: 3000, 289 + IPFS_ENABLED: true, 290 + IPFS_NETWORKING: false, 291 + REPLICATE_DIDS: ["did:plc:remote1", "did:plc:remote2"], 292 + FIREHOSE_URL: "wss://localhost/xrpc/com.atproto.sync.subscribeRepos", 293 + FIREHOSE_ENABLED: false, 294 + }; 295 + 296 + const { RepoManager } = await import("../repo-manager.js"); 297 + const repoManager = new RepoManager(db, config); 298 + repoManager.init(); 299 + 300 + const mockNet = createMockNetworkService(); 301 + const mockBlocks = createMockBlockStore(); 302 + const didResolver = new DidResolver(); 303 + 304 + const manager = new ReplicationManager( 305 + db, 306 + config, 307 + repoManager, 308 + mockBlocks, 309 + mockNet, 310 + didResolver, 311 + ); 312 + 313 + try { 314 + await manager.init(); 315 + 316 + // Verify subscribeCommitTopics was called with tracked DIDs 317 + expect(mockNet.subscribeCommitTopics).toHaveBeenCalled(); 318 + expect(mockNet._subscribedDids).toContain("did:plc:remote1"); 319 + expect(mockNet._subscribedDids).toContain("did:plc:remote2"); 320 + 321 + // Verify onCommitNotification was called to register a handler 322 + expect(mockNet.onCommitNotification).toHaveBeenCalled(); 323 + expect(mockNet._handlers.length).toBeGreaterThan(0); 324 + } finally { 325 + manager.stop(); 326 + db.close(); 327 + } 328 + }); 329 + 330 + it("dedup: same rev notification does not trigger re-sync", async () => { 331 + const Database = (await import("better-sqlite3")).default; 332 + const { ReplicationManager } = await import("./replication-manager.js"); 333 + const { DidResolver } = await import("../did-resolver.js"); 334 + 335 + const db = new Database(join(tmpDir, "test-dedup.db")); 336 + const config = { 337 + DID: "did:plc:local", 338 + HANDLE: "test.example.com", 339 + PDS_HOSTNAME: "test.example.com", 340 + AUTH_TOKEN: "test", 341 + SIGNING_KEY: "0000000000000000000000000000000000000000000000000000000000000001", 342 + SIGNING_KEY_PUBLIC: "zQ3shP2mWsZYWgvZM9GJ3EvMfRXQJwuTh6BdXLvJB9gFhT3Lr", 343 + JWT_SECRET: "test", 344 + PASSWORD_HASH: "$2a$10$test", 345 + DATA_DIR: tmpDir, 346 + PORT: 3000, 347 + IPFS_ENABLED: true, 348 + IPFS_NETWORKING: false, 349 + REPLICATE_DIDS: ["did:plc:remote1"], 350 + FIREHOSE_URL: "wss://localhost/xrpc/com.atproto.sync.subscribeRepos", 351 + FIREHOSE_ENABLED: false, 352 + }; 353 + 354 + const { RepoManager } = await import("../repo-manager.js"); 355 + const repoManager = new RepoManager(db, config); 356 + repoManager.init(); 357 + 358 + const mockNet = createMockNetworkService(); 359 + const mockBlocks = createMockBlockStore(); 360 + const didResolver = new DidResolver(); 361 + 362 + const manager = new ReplicationManager( 363 + db, 364 + config, 365 + repoManager, 366 + mockBlocks, 367 + mockNet, 368 + didResolver, 369 + ); 370 + 371 + try { 372 + await manager.init(); 373 + 374 + // Spy on syncDid to see if it gets called 375 + const syncDidSpy = vi.spyOn(manager, "syncDid").mockResolvedValue(undefined); 376 + 377 + const notification: CommitNotification = { 378 + did: "did:plc:remote1", 379 + commit: "bafyreiabc", 380 + rev: "3jui7kd2xxxx2", 381 + time: new Date().toISOString(), 382 + peer: "12D3KooWOtherPeer", 383 + }; 384 + 385 + // First notification should trigger syncDid 386 + mockNet._simulateNotification(notification); 387 + await new Promise((r) => setTimeout(r, 100)); 388 + expect(syncDidSpy).toHaveBeenCalledTimes(1); 389 + 390 + // Same rev notification should be deduped 391 + mockNet._simulateNotification(notification); 392 + await new Promise((r) => setTimeout(r, 100)); 393 + expect(syncDidSpy).toHaveBeenCalledTimes(1); // still 1 394 + 395 + // Different rev should trigger another syncDid 396 + mockNet._simulateNotification({ 397 + ...notification, 398 + rev: "3jui7kd2yyyy3", 399 + }); 400 + await new Promise((r) => setTimeout(r, 100)); 401 + expect(syncDidSpy).toHaveBeenCalledTimes(2); 402 + 403 + syncDidSpy.mockRestore(); 404 + } finally { 405 + manager.stop(); 406 + db.close(); 407 + } 408 + }); 409 + 410 + it("notification for untracked DID is ignored", async () => { 411 + const Database = (await import("better-sqlite3")).default; 412 + const { ReplicationManager } = await import("./replication-manager.js"); 413 + const { DidResolver } = await import("../did-resolver.js"); 414 + 415 + const db = new Database(join(tmpDir, "test-untracked.db")); 416 + const config = { 417 + DID: "did:plc:local", 418 + HANDLE: "test.example.com", 419 + PDS_HOSTNAME: "test.example.com", 420 + AUTH_TOKEN: "test", 421 + SIGNING_KEY: "0000000000000000000000000000000000000000000000000000000000000001", 422 + SIGNING_KEY_PUBLIC: "zQ3shP2mWsZYWgvZM9GJ3EvMfRXQJwuTh6BdXLvJB9gFhT3Lr", 423 + JWT_SECRET: "test", 424 + PASSWORD_HASH: "$2a$10$test", 425 + DATA_DIR: tmpDir, 426 + PORT: 3000, 427 + IPFS_ENABLED: true, 428 + IPFS_NETWORKING: false, 429 + REPLICATE_DIDS: ["did:plc:remote1"], 430 + FIREHOSE_URL: "wss://localhost/xrpc/com.atproto.sync.subscribeRepos", 431 + FIREHOSE_ENABLED: false, 432 + }; 433 + 434 + const { RepoManager } = await import("../repo-manager.js"); 435 + const repoManager = new RepoManager(db, config); 436 + repoManager.init(); 437 + 438 + const mockNet = createMockNetworkService(); 439 + const mockBlocks = createMockBlockStore(); 440 + const didResolver = new DidResolver(); 441 + 442 + const manager = new ReplicationManager( 443 + db, 444 + config, 445 + repoManager, 446 + mockBlocks, 447 + mockNet, 448 + didResolver, 449 + ); 450 + 451 + try { 452 + await manager.init(); 453 + 454 + const syncDidSpy = vi.spyOn(manager, "syncDid").mockResolvedValue(undefined); 455 + 456 + // Notification for a DID we're NOT tracking 457 + mockNet._simulateNotification({ 458 + did: "did:plc:unknown", 459 + commit: "bafyreiabc", 460 + rev: "3jui7kd2xxxx2", 461 + time: new Date().toISOString(), 462 + peer: "12D3KooWOther", 463 + }); 464 + await new Promise((r) => setTimeout(r, 100)); 465 + 466 + expect(syncDidSpy).not.toHaveBeenCalled(); 467 + 468 + syncDidSpy.mockRestore(); 469 + } finally { 470 + manager.stop(); 471 + db.close(); 472 + } 473 + }); 474 + }); 475 + 476 + // ============================================ 477 + // Helpers 478 + // ============================================ 479 + 480 + async function waitFor( 481 + fn: () => Promise<boolean> | boolean, 482 + timeoutMs: number = 10_000, 483 + intervalMs: number = 200, 484 + ): Promise<void> { 485 + const deadline = Date.now() + timeoutMs; 486 + while (Date.now() < deadline) { 487 + if (await fn()) return; 488 + await new Promise((r) => setTimeout(r, intervalMs)); 489 + } 490 + throw new Error(`waitFor timed out after ${timeoutMs}ms`); 491 + }
+72 -1
src/replication/replication-manager.ts
··· 9 9 import type { RepoManager } from "../repo-manager.js"; 10 10 import { extractBlobCids } from "../repo-manager.js"; 11 11 import { create as createCid, CODEC_RAW, toString as cidToString } from "@atcute/cid"; 12 - import type { BlockStore, NetworkService } from "../ipfs.js"; 12 + import type { BlockStore, NetworkService, CommitNotification } from "../ipfs.js"; 13 13 import type { DidResolver } from "../did-resolver.js"; 14 14 import { readCarWithRoot } from "@atproto/repo"; 15 15 import { decode as cborDecode } from "../cbor-compat.js"; ··· 70 70 private offerManager: OfferManager | null = null; 71 71 /** Per-DID last-sync timestamps (epoch ms) for policy-driven interval tracking. */ 72 72 private lastSyncTimestamps: Map<string, number> = new Map(); 73 + /** Dedup set for gossipsub notifications, keyed by `${did}:${rev}`. */ 74 + private recentNotifications: Set<string> = new Set(); 75 + private notificationCleanupTimer: ReturnType<typeof setInterval> | null = null; 73 76 74 77 constructor( 75 78 db: Database.Database, ··· 130 133 await this.syncManifests(); 131 134 await this.discoverPeerEndpoints(); 132 135 await this.runOfferDiscovery(); 136 + this.setupGossipsubSubscription(); 133 137 } 134 138 135 139 /** ··· 514 518 // 9. Update sync state with both rev and root CID 515 519 this.syncStorage.updateSyncProgress(did, rev, rootCidStr); 516 520 521 + // 9a. Publish gossipsub notification (fire-and-forget) 522 + this.networkService.publishCommitNotification(did, rootCidStr, rev).catch(() => {}); 523 + 517 524 // 9b. Invalidate cached ReadableRepo so it reloads with new root 518 525 this.replicatedRepoReader?.invalidateCache(did); 519 526 ··· 806 813 // 7. Update sync state 807 814 this.syncStorage.updateSyncProgress(did, rev, rootCidStr); 808 815 816 + // 7a. Publish gossipsub notification (fire-and-forget) 817 + this.networkService.publishCommitNotification(did, rootCidStr, rev).catch(() => {}); 818 + 809 819 // 8. Invalidate cached ReadableRepo so it reloads with new root 810 820 this.replicatedRepoReader?.invalidateCache(did); 811 821 ··· 885 895 } 886 896 887 897 /** 898 + * Set up gossipsub subscription for commit notifications. 899 + * Subscribes to topics for all tracked DIDs and registers a handler 900 + * that triggers syncDid() when a newer rev is received. 901 + */ 902 + private setupGossipsubSubscription(): void { 903 + const dids = this.getReplicateDids(); 904 + if (dids.length === 0) return; 905 + 906 + this.networkService.onCommitNotification((notification) => { 907 + this.handleGossipsubNotification(notification).catch((err) => { 908 + console.error( 909 + `[replication] Gossipsub notification handler error for ${notification.did}:`, 910 + err instanceof Error ? err.message : String(err), 911 + ); 912 + }); 913 + }); 914 + 915 + this.networkService.subscribeCommitTopics(dids); 916 + 917 + // Periodically clear the dedup set to prevent unbounded growth 918 + this.notificationCleanupTimer = setInterval(() => { 919 + this.recentNotifications.clear(); 920 + }, 60_000); 921 + } 922 + 923 + /** 924 + * Handle an incoming gossipsub commit notification. 925 + * Deduplicates by did:rev, skips if local state is already current, 926 + * and triggers syncDid() if the notification represents a newer commit. 927 + */ 928 + private async handleGossipsubNotification(notification: CommitNotification): Promise<void> { 929 + const did = notification.did; 930 + 931 + // Only process DIDs we are tracking 932 + const trackedDids = this.getReplicateDids(); 933 + if (!trackedDids.includes(did)) return; 934 + 935 + // Dedup: skip if we've already processed this did:rev 936 + const dedupKey = `${did}:${notification.rev}`; 937 + if (this.recentNotifications.has(dedupKey)) return; 938 + this.recentNotifications.add(dedupKey); 939 + 940 + // Skip if local state already matches this rev 941 + const state = this.syncStorage.getState(did); 942 + if (state?.lastSyncRev === notification.rev) return; 943 + 944 + try { 945 + await this.syncDid(did); 946 + this.lastSyncTimestamps.set(did, Date.now()); 947 + } catch (err) { 948 + const message = err instanceof Error ? err.message : String(err); 949 + this.syncStorage.updateStatus(did, "error", message); 950 + } 951 + } 952 + 953 + /** 888 954 * Stop periodic sync, verification, and firehose subscription. 889 955 */ 890 956 stop(): void { ··· 911 977 if (this.challengeScheduler) { 912 978 this.challengeScheduler.stop(); 913 979 this.challengeScheduler = null; 980 + } 981 + // Stop gossipsub notification cleanup 982 + if (this.notificationCleanupTimer) { 983 + clearInterval(this.notificationCleanupTimer); 984 + this.notificationCleanupTimer = null; 914 985 } 915 986 } 916 987
+7 -1
src/repo-manager.ts
··· 174 174 cidStrs.push(cidStr); 175 175 } 176 176 } 177 - return net.provideBlocks(cidStrs); 177 + return net.provideBlocks(cidStrs).then(() => { 178 + net.publishCommitNotification( 179 + commitData.did, 180 + commitData.commit.toString(), 181 + commitData.rev, 182 + ).catch(() => {}); 183 + }); 178 184 }) 179 185 .catch(() => {}); 180 186 }