| … | … | @@ -37,7 +37,7 @@ import { TreeStore, DEFAULT_ROOT as PGH_STORE_ROOT } from 'pgh/store' |
| 37 | 37 | import { loadOrCreateIdentity } from 'pgh/identity' |
| 38 | 38 | import { ReleaseRegistry } from 'pgh/release-registry' |
| 39 | 39 | import { NameRegistry } from 'pgh/name-registry' |
| 40 | | import { readSubscriptions as readSubscriptionsFromDisk } from 'pgh/subscriptions' |
| 40 | import { readSubscriptions as readSubscriptionsFromDisk, readNameSubscriptions, addNameSubscription } from 'pgh/subscriptions' |
| 41 | 41 | |
| 42 | 42 | import { OverlayManager } from './overlay-manager.js' |
| 43 | 43 | import { SyncEngine } from 'hyper-overlay/sync-engine' |
| … | … | @@ -109,6 +109,15 @@ export class Sidecar { |
| 109 | 109 | : [] |
| 110 | 110 | this.releaseRegistrySubscriptions = opts.releaseRegistrySubscriptions || |
| 111 | 111 | [...new Set([...fromConfig, ...fromEnv])] |
| 112 | // NameRegistry subscriptions — read-only replicas of OTHER publishers' |
| 113 | // name registries so bare app-name resolution works cross-publisher. |
| 114 | // Sourced from subscriptions.json `nameRegistries` + PGH_NAME_REGISTRIES. |
| 115 | const nameFromConfig = readNameSubscriptions() |
| 116 | const nameFromEnv = process.env.PGH_NAME_REGISTRIES |
| 117 | ? process.env.PGH_NAME_REGISTRIES.split(',').map(s => s.trim()).filter(Boolean) |
| 118 | : [] |
| 119 | this.nameRegistrySubscriptions = opts.nameRegistrySubscriptions || |
| 120 | [...new Set([...nameFromConfig, ...nameFromEnv])] |
| 112 | 121 | this.passphrase = opts.passphrase || null |
| 113 | 122 | // Seeding can be disabled in tests (no swarm / no DeployServer) |
| 114 | 123 | this.enableSeed = opts.enableSeed !== false |
| … | … | @@ -123,6 +132,7 @@ export class Sidecar { |
| 123 | 132 | this._deployServer = null // hyper-deploy DeployServer (advertises trees) |
| 124 | 133 | this._registrySwarm = null // Hyperswarm advertising the release-registry |
| 125 | 134 | this._releaseReplicas = [] // [{ keyHex, store, registry, swarm }] — warm replicas |
| 135 | this._nameReplicas = [] // [{ keyHex, store, registry, swarm }] — read-only NameRegistry replicas |
| 126 | 136 | // Running-app registry. Apps register with us on boot so `pgh app |
| 127 | 137 | // resync|start|stop|restart|status` can find their UDS. Persisted |
| 128 | 138 | // to disk so sidecar restarts don't lose the map — otherwise every |
| … | … | @@ -289,6 +299,17 @@ export class Sidecar { |
| 289 | 299 | this._nameRegistrySwarm.join(this._nameRegistry.discoveryKey, { server: true, client: true }) |
| 290 | 300 | await this._nameRegistrySwarm.flush() |
| 291 | 301 | |
| 302 | // Read-only replicas of OTHER publishers' name registries, so a |
| 303 | // standalone peer (own empty NameRegistry) can resolve bare app |
| 304 | // names claimed elsewhere. Mirrors the release-replica scheme below. |
| 305 | const ownNameKey = this._nameRegistry && this._nameRegistry.keyHex |
| 306 | for (const keyHex of this.nameRegistrySubscriptions) { |
| 307 | if (keyHex.length !== 64) continue |
| 308 | if (keyHex === ownNameKey) continue // own registry already queried first |
| 309 | const replica = await this._openNameReplica(keyHex) |
| 310 | if (replica) this._nameReplicas.push(replica) |
| 311 | } |
| 312 | |
| 292 | 313 | // Warm read-only replicas of other publishers' release registries. |
| 293 | 314 | // These stay live in the background via autobase update events — |
| 294 | 315 | // CLI queries them via UDS instead of opening one-shot replicas. |
| … | … | @@ -461,6 +482,12 @@ export class Sidecar { |
| 461 | 482 | try { await r.store.close() } catch {} |
| 462 | 483 | } |
| 463 | 484 | this._releaseReplicas = [] |
| 485 | for (const r of this._nameReplicas) { |
| 486 | try { await r.swarm.destroy() } catch {} |
| 487 | try { await r.registry.close() } catch {} |
| 488 | try { await r.store.close() } catch {} |
| 489 | } |
| 490 | this._nameReplicas = [] |
| 464 | 491 | if (this._deployServer) { |
| 465 | 492 | try { await this._deployServer.close() } catch {} |
| 466 | 493 | this._deployServer = null |
| … | … | @@ -1088,6 +1115,55 @@ export class Sidecar { |
| 1088 | 1115 | } |
| 1089 | 1116 | } |
| 1090 | 1117 | |
| 1118 | /** |
| 1119 | * Open a read-only replica of another publisher's NameRegistry, joined |
| 1120 | * on its discoveryKey so it stays warm. Lets `resolve-name` find names |
| 1121 | * claimed in registries this sidecar doesn't own. Mirrors |
| 1122 | * _openReleaseReplica. Returns { keyHex, store, registry, swarm } or null. |
| 1123 | */ |
| 1124 | async _openNameReplica (keyHex) { |
| 1125 | try { |
| 1126 | const dir = path.join(homedir(), '.config', 'pgh', 'name-registries', keyHex.slice(0, 16)) |
| 1127 | fs.mkdirSync(dir, { recursive: true }) |
| 1128 | const store = new Corestore(dir) |
| 1129 | const registry = new NameRegistry(store, { bootstrap: keyHex }) |
| 1130 | await registry.ready() |
| 1131 | const swarm = new Hyperswarm() |
| 1132 | swarm.on('error', (err) => { |
| 1133 | console.error('[pgh-sidecar] name-replica-swarm(' + keyHex.slice(0, 12) + ') error (non-fatal):', err.code || err.message) |
| 1134 | }) |
| 1135 | swarm.on('connection', (conn) => { |
| 1136 | conn.on('error', () => {}) |
| 1137 | store.replicate(conn) |
| 1138 | }) |
| 1139 | swarm.join(registry.discoveryKey, { server: true, client: true }) |
| 1140 | await swarm.flush() |
| 1141 | console.log('[pgh-sidecar] name-registry replica live: ' + keyHex.slice(0, 12) + '…') |
| 1142 | return { keyHex, store, registry, swarm } |
| 1143 | } catch (err) { |
| 1144 | console.error('[pgh-sidecar] failed to open name-registry replica ' + keyHex.slice(0, 12) + ':', err.message) |
| 1145 | return null |
| 1146 | } |
| 1147 | } |
| 1148 | |
| 1149 | /** |
| 1150 | * Resolve a bare name across the own NameRegistry first, then every |
| 1151 | * subscribed read-only replica. Returns the entry or null. |
| 1152 | */ |
| 1153 | async _resolveNameAcross (name) { |
| 1154 | let entry = null |
| 1155 | try { entry = await this._nameRegistry.resolve(name) } catch {} |
| 1156 | if (entry) return entry |
| 1157 | for (const r of this._nameReplicas) { |
| 1158 | try { |
| 1159 | await r.registry.update() |
| 1160 | const e = await r.registry.resolve(name) |
| 1161 | if (e) return e |
| 1162 | } catch {} |
| 1163 | } |
| 1164 | return null |
| 1165 | } |
| 1166 | |
| 1091 | 1167 | /** |
| 1092 | 1168 | * Find a live registry (own writable or warm replica) for the given |
| 1093 | 1169 | * registryKeyHex. Returns the ReleaseRegistry instance or null. |
| … | … | @@ -1304,10 +1380,39 @@ export class Sidecar { |
| 1304 | 1380 | |
| 1305 | 1381 | svc.handle('resolve-name', async (ctx, params) => { |
| 1306 | 1382 | if (!params || !params.name) throw new Error('resolve-name requires { name }') |
| 1307 | | const entry = await this._nameRegistry.resolve(params.name) |
| 1383 | // Own registry first, then subscribed read-only replicas. |
| 1384 | const entry = await this._resolveNameAcross(params.name) |
| 1308 | 1385 | return entry || null |
| 1309 | 1386 | }) |
| 1310 | 1387 | |
| 1388 | // Subscribe to another publisher's NameRegistry by autobase key: |
| 1389 | // persists to subscriptions.json AND opens a live read-only replica |
| 1390 | // immediately (no sidecar restart). Idempotent. |
| 1391 | svc.handle('subscribe-name-registry', async (ctx, params) => { |
| 1392 | const keyHex = params && params.registryKey |
| 1393 | if (!keyHex || typeof keyHex !== 'string' || keyHex.length !== 64) { |
| 1394 | throw new Error('subscribe-name-registry requires { registryKey } (64-char hex)') |
| 1395 | } |
| 1396 | const ownNameKey = this._nameRegistry && this._nameRegistry.keyHex |
| 1397 | if (keyHex === ownNameKey) { |
| 1398 | return { ok: true, alreadyOwn: true, subscriptions: this._nameReplicas.map(r => r.keyHex) } |
| 1399 | } |
| 1400 | const persisted = addNameSubscription(keyHex) |
| 1401 | let opened = false |
| 1402 | if (!this._nameReplicas.some(r => r.keyHex === keyHex)) { |
| 1403 | const replica = await this._openNameReplica(keyHex) |
| 1404 | if (replica) { this._nameReplicas.push(replica); opened = true } |
| 1405 | else throw new Error('failed to open name-registry replica for ' + keyHex.slice(0, 12)) |
| 1406 | } |
| 1407 | return { ok: true, persisted, opened, subscriptions: this._nameReplicas.map(r => r.keyHex) } |
| 1408 | }) |
| 1409 | |
| 1410 | // List name-registry subscriptions (own key + replica keys). |
| 1411 | svc.handle('name-subscriptions', async () => ({ |
| 1412 | own: this._nameRegistry ? this._nameRegistry.keyHex : null, |
| 1413 | replicas: this._nameReplicas.map(r => ({ keyHex: r.keyHex, writable: r.registry.writable })) |
| 1414 | })) |
| 1415 | |
| 1311 | 1416 | svc.handle('claim-name', async (ctx, params) => { |
| 1312 | 1417 | if (!params || !params.name) throw new Error('claim-name requires { name }') |
| 1313 | 1418 | if (!this._nameRegistry.writable) { |
| … | … | @@ -1351,7 +1456,18 @@ export class Sidecar { |
| 1351 | 1456 | }) |
| 1352 | 1457 | |
| 1353 | 1458 | svc.handle('list-names', async (ctx, params) => { |
| 1354 | | return await this._nameRegistry.list(params || {}) |
| 1459 | // Merge own registry + subscribed replicas, dedup by name (own wins). |
| 1460 | const seen = new Map() |
| 1461 | const own = await this._nameRegistry.list(params || {}) |
| 1462 | for (const e of (own || [])) if (e && e.name) seen.set(e.name, e) |
| 1463 | for (const r of this._nameReplicas) { |
| 1464 | try { |
| 1465 | await r.registry.update() |
| 1466 | const list = await r.registry.list(params || {}) |
| 1467 | for (const e of (list || [])) if (e && e.name && !seen.has(e.name)) seen.set(e.name, e) |
| 1468 | } catch {} |
| 1469 | } |
| 1470 | return [...seen.values()] |
| 1355 | 1471 | }) |
| 1356 | 1472 | |
| 1357 | 1473 | // Stubs — declared to make the UDS surface predictable. Real logic |