Skip to content

Commit 574ecbb

Browse files
committed
fix(devframe): dedup wire-service install across contexts sharing an RPC host
A single wire service declared on a definition could register its RPC twice and trip DF0021 when the definition is mounted into two contexts backed by one RPC host (e.g. a kit mounting it alongside another context, so both iterate def.services). The install-dedup guard was per services-host instance, so the second context re-ran the service factory and re-registered its RPC. Key the dedup registry by the shared RPC host so the first install wins across sibling contexts; a sibling hit reuses the cached API without re-running setup, while a genuine same-host re-install still warns DF0066.
1 parent fecbf1f commit 574ecbb

2 files changed

Lines changed: 75 additions & 4 deletions

File tree

packages/devframe/src/node/__tests__/services.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,38 @@ describe('wire services (install / ready)', () => {
205205
expect(warn.mock.calls.flat().join('\n')).toContain('DF0066')
206206
})
207207

208+
it('installs a service once across two contexts that share one RPC host (no DF0021)', async () => {
209+
const { ctx } = await createCtx()
210+
const { ctx: ctx2 } = await createCtx()
211+
// A kit/hub can mount a definition into two contexts backed by a single
212+
// RPC host; both then iterate `def.services` and install the same service.
213+
;(ctx2 as { rpc: unknown }).rpc = ctx.rpc
214+
215+
let setupRuns = 0
216+
const declare = () => defineTestService({
217+
setup: (scoped) => {
218+
setupRuns++
219+
scoped.rpc.register({ name: 'ping', handler: () => 'pong' })
220+
return { ok: true }
221+
},
222+
})
223+
224+
void ctx.services.install(declare())
225+
await ctx.services.ready()
226+
227+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
228+
void ctx2.services.install(declare())
229+
await expect(ctx2.services.ready()).resolves.toBeUndefined()
230+
231+
// The factory ran once; the RPC is registered once and stays callable.
232+
expect(setupRuns).toBe(1)
233+
expect(warn.mock.calls.flat().join('\n')).not.toContain('DF0021')
234+
await expect((ctx.rpc.invokeLocal as (m: string) => Promise<unknown>)('test:svc:ping')).resolves.toBe('pong')
235+
// Both contexts expose the service API for in-process `get`.
236+
expect(ctx.services.get('@test/svc')).toEqual({ ok: true })
237+
expect(ctx2.services.get('@test/svc')).toEqual({ ok: true })
238+
})
239+
208240
it('skips an optional descriptor whose package cannot be imported', async () => {
209241
const { ctx } = await createCtx()
210242
const install = ctx.services.install({ package: '@test/does-not-exist' })

packages/devframe/src/node/host-services.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ import { deepMergeOptionSets, expandResolveFrom, importServicePackage, satisfies
1616

1717
const debug = createDebug('devframe:services')
1818

19+
/**
20+
* Per-RPC-host registry of the services already installed against it, keyed
21+
* by the RPC host object. A single wire service can be declared from more
22+
* than one devframe context that shares one RPC host (e.g. a kit that mounts
23+
* a definition alongside another context, so both iterate `def.services`).
24+
* The per-instance `installed` guard can't see across those sibling hosts, so
25+
* the second context would re-run the service factory and re-register its RPC,
26+
* hitting DF0021. Sharing the registry by RPC host makes the first install win
27+
* across every context on that host.
28+
*/
29+
const installedByRpcHost = new WeakMap<object, Map<string, unknown>>()
30+
1931
interface PendingServiceEntry {
2032
input: DevframeServiceInput
2133
resolveFrom?: string | null
@@ -72,11 +84,29 @@ export class DevframeServicesHostImpl implements DevframeServicesHost {
7284
private services = new Map<string, unknown>()
7385
private listeners = new Map<string, Set<(service: unknown) => void>>()
7486
private pending = new Map<string, PendingServiceEntry[]>()
75-
private installed = new Map<string, unknown>()
87+
private localInstalled = new Map<string, unknown>()
7688
private readyPromise: Promise<void> | undefined
7789

7890
constructor(private context?: DevframeNodeContext) {}
7991

92+
/**
93+
* The install-dedup registry, shared across every services host that shares
94+
* this host's RPC host so the first install of a package wins across sibling
95+
* contexts. Falls back to a per-instance map when there is no RPC host (a
96+
* context-less host only exercises the in-process `provide`/`get` tier).
97+
*/
98+
private get installed(): Map<string, unknown> {
99+
const rpc = this.context?.rpc as object | undefined
100+
if (!rpc)
101+
return this.localInstalled
102+
let registry = installedByRpcHost.get(rpc)
103+
if (!registry) {
104+
registry = new Map()
105+
installedByRpcHost.set(rpc, registry)
106+
}
107+
return registry
108+
}
109+
80110
provide<ID extends DevframeServiceId>(id: ID, service: DevframeServiceOf<ID>): () => void {
81111
const key = id as string
82112
if (this.services.has(key))
@@ -190,8 +220,17 @@ export class DevframeServicesHostImpl implements DevframeServicesHost {
190220
private async installPackage(pkg: string, entries: PendingServiceEntry[]): Promise<unknown> {
191221
// Dedup: first installation wins; a later install's options are ignored.
192222
if (this.installed.has(pkg)) {
193-
diagnostics.DF0066({ package: pkg })
194-
return this.installed.get(pkg)
223+
const api = this.installed.get(pkg)
224+
// A sibling context sharing this RPC host already constructed the
225+
// service. Expose the cached API here too, but skip re-running the
226+
// factory (which would re-register its RPC and hit DF0021). Only a
227+
// re-install on the same host (it already provides it) is the noisy
228+
// duplicate DF0066 warns about.
229+
if (this.services.has(pkg))
230+
diagnostics.DF0066({ package: pkg })
231+
else
232+
this.provide(pkg, api as DevframeServiceOf<string>)
233+
return api
195234
}
196235

197236
const definitions = entries.filter(entry => isServiceDefinition(entry.input))
@@ -211,7 +250,7 @@ export class DevframeServicesHostImpl implements DevframeServicesHost {
211250
debug('installing service %s@%s (scope %s)', def.package, def.version, def.scope)
212251
const scoped = this.context.scope(def.scope)
213252
const api = await def.setup(scoped, options === undefined ? {} : { options })
214-
this.installed.set(def.package, api)
253+
this.installed.set(pkg, api)
215254
this.provide(def.package, api as DevframeServiceOf<string>)
216255
await this.advertise(def)
217256
return api

0 commit comments

Comments
 (0)