diff --git a/docs/guide/rpc.md b/docs/guide/rpc.md index 087fdce1..6a185a0c 100644 --- a/docs/guide/rpc.md +++ b/docs/guide/rpc.md @@ -231,6 +231,8 @@ declare module 'devframe' { For server→client calls invoked via `ctx.rpc.broadcast`, augment `DevframeRpcClientFunctions` the same way. +Augment one of the canonical module specifiers where these interfaces live — `declare module 'devframe'` or `declare module 'devframe/types'` (the form `@devframes/hub` uses). A wrapper package that re-exports the interface under a renamed alias (e.g. `DevToolsRpcServerFunctions`) is a different declaration, so augmenting the alias no longer merges into the base interface. + ## Static dumps For `static` functions, Devframe records the handler's output during `createBuild` and bakes it into the build: diff --git a/packages/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts index ab8e1521..90379e23 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -2,11 +2,11 @@ import type { RpcFunctionDefinitionAnyWithContext } from '../../rpc/types' import type { DevframeNodeContext } from '../../types/context' import { describe, expect, it, vi } from 'vitest' import { DevframeAgentHost } from '../host-agent' -import { RpcFunctionsHost } from '../host-functions' +import { RpcFunctionsHostImpl } from '../host-functions' function createContext(): DevframeNodeContext { const ctx = {} as DevframeNodeContext - ctx.rpc = new RpcFunctionsHost(ctx) + ctx.rpc = new RpcFunctionsHostImpl(ctx) ctx.agent = new DevframeAgentHost(ctx) return ctx } diff --git a/packages/devframe/src/node/__tests__/host-functions.test.ts b/packages/devframe/src/node/__tests__/host-functions.test.ts index 396ddcc5..6c3d0c59 100644 --- a/packages/devframe/src/node/__tests__/host-functions.test.ts +++ b/packages/devframe/src/node/__tests__/host-functions.test.ts @@ -1,7 +1,8 @@ import type { DevframeNodeContext } from 'devframe/types' +import type { RpcFunctionsHost } from '../host-functions' import { defineRpcFunction } from 'devframe' -import { describe, expect, it } from 'vitest' -import { RpcFunctionsHost } from '../host-functions' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { RpcFunctionsHostImpl } from '../host-functions' async function emptyHandler() { /* empty */ } const returnFirst = async () => 'first' @@ -15,7 +16,7 @@ describe('rpcFunctionsHost', () => { describe('register() collision detection', () => { it('should register a new RPC function successfully', () => { - const host = new RpcFunctionsHost(mockContext) + const host = new RpcFunctionsHostImpl(mockContext) const fn = defineRpcFunction({ name: 'test-function', type: 'action', @@ -27,7 +28,7 @@ describe('rpcFunctionsHost', () => { }) it('should throw error when registering duplicate RPC function ID', () => { - const host = new RpcFunctionsHost(mockContext) + const host = new RpcFunctionsHostImpl(mockContext) const fn1 = defineRpcFunction({ name: 'duplicate-fn', type: 'action', @@ -48,7 +49,7 @@ describe('rpcFunctionsHost', () => { }) it('should include the duplicate ID in error message', () => { - const host = new RpcFunctionsHost(mockContext) + const host = new RpcFunctionsHostImpl(mockContext) const fn = defineRpcFunction({ name: 'my-special-function', type: 'query', @@ -64,7 +65,7 @@ describe('rpcFunctionsHost', () => { describe('update() existence validation', () => { it('should throw error when updating non-existent RPC function', () => { - const host = new RpcFunctionsHost(mockContext) + const host = new RpcFunctionsHostImpl(mockContext) const fn = defineRpcFunction({ name: 'nonexistent', type: 'action', @@ -79,7 +80,7 @@ describe('rpcFunctionsHost', () => { }) it('should update existing RPC function successfully', () => { - const host = new RpcFunctionsHost(mockContext) + const host = new RpcFunctionsHostImpl(mockContext) const fn1 = defineRpcFunction({ name: 'update-test', type: 'action', @@ -100,7 +101,7 @@ describe('rpcFunctionsHost', () => { }) it('should validate that update only works on existing entries', () => { - const host = new RpcFunctionsHost(mockContext) + const host = new RpcFunctionsHostImpl(mockContext) // Register one function host.register(defineRpcFunction({ @@ -131,7 +132,7 @@ describe('rpcFunctionsHost', () => { describe('broadcast() without rpc group', () => { it('should not throw in build mode', async () => { - const host = new RpcFunctionsHost({ mode: 'build' } as DevframeNodeContext) + const host = new RpcFunctionsHostImpl({ mode: 'build' } as DevframeNodeContext) await expect(host.broadcast({ method: 'devframe:auth:revoked', args: [], @@ -139,7 +140,7 @@ describe('rpcFunctionsHost', () => { }) it('should not throw in dev mode when rpc group is not yet set', async () => { - const host = new RpcFunctionsHost({ mode: 'dev' } as DevframeNodeContext) + const host = new RpcFunctionsHostImpl({ mode: 'dev' } as DevframeNodeContext) await expect(host.broadcast({ method: 'devframe:auth:revoked', args: [], @@ -149,7 +150,7 @@ describe('rpcFunctionsHost', () => { describe('invokeLocal()', () => { it('should invoke a locally registered function', async () => { - const host = new RpcFunctionsHost(mockContext) + const host = new RpcFunctionsHostImpl(mockContext) host.register(defineRpcFunction({ name: 'test:invoke-local', type: 'query', @@ -162,8 +163,29 @@ describe('rpcFunctionsHost', () => { }) it('should throw when invoking a missing local function', async () => { - const host = new RpcFunctionsHost(mockContext) + const host = new RpcFunctionsHostImpl(mockContext) await expect(host.invokeLocal('test:missing' as any)).rejects.toThrow('RPC function "test:missing" is not registered') }) }) + + describe('public RpcFunctionsHost type', () => { + it('is the structural type that ctx.rpc satisfies', () => { + // The `RpcFunctionsHost` exported from `devframe/node` (this + // re-export) must be the exact same structural type as + // `DevframeNodeContext['rpc']`, so consumers can cast `ctx.rpc` + // to it without a TS2352 conversion error. + expectTypeOf().toEqualTypeOf() + expectTypeOf().toMatchTypeOf() + }) + + it('does not carry the impl-only @internal members', () => { + const rpc = {} as RpcFunctionsHost + // @ts-expect-error `_rpcGroup` is impl-only and must stay off the public type + void rpc._rpcGroup + // @ts-expect-error `_asyncStorage` is impl-only and must stay off the public type + void rpc._asyncStorage + // @ts-expect-error `_emitSessionDisconnected` is impl-only and must stay off the public type + void rpc._emitSessionDisconnected + }) + }) }) diff --git a/packages/devframe/src/node/__tests__/rpc-streaming.test.ts b/packages/devframe/src/node/__tests__/rpc-streaming.test.ts index 26313c64..d3cdb804 100644 --- a/packages/devframe/src/node/__tests__/rpc-streaming.test.ts +++ b/packages/devframe/src/node/__tests__/rpc-streaming.test.ts @@ -9,20 +9,20 @@ import { getPort } from 'get-port-please' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' -import { RpcFunctionsHost } from '../host-functions' +import { RpcFunctionsHostImpl } from '../host-functions' vi.stubGlobal('WebSocket', WebSocket) interface Harness { port: number - rpcHost: RpcFunctionsHost + rpcHost: RpcFunctionsHostImpl close: () => Promise } async function bootHost(): Promise { const port = await getPort({ host: '127.0.0.1', random: true }) const mockContext = {} as DevframeNodeContext - const rpcHost = new RpcFunctionsHost(mockContext) + const rpcHost = new RpcFunctionsHostImpl(mockContext) const asyncStorage = new AsyncLocalStorage() diff --git a/packages/devframe/src/node/auth/revoke.ts b/packages/devframe/src/node/auth/revoke.ts index 541505f0..235e099d 100644 --- a/packages/devframe/src/node/auth/revoke.ts +++ b/packages/devframe/src/node/auth/revoke.ts @@ -1,6 +1,6 @@ import type { DevframeNodeContext } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' -import type { RpcFunctionsHost } from '../host-functions' +import type { RpcFunctionsHostImpl } from '../host-functions' import type { InternalAnonymousAuthStorage } from '../hub-internals/context' /** @@ -13,7 +13,7 @@ export async function revokeActiveConnectionsForToken( context: DevframeNodeContext, token: string, ): Promise { - const rpcHost = context.rpc as unknown as RpcFunctionsHost | undefined + const rpcHost = context.rpc as unknown as RpcFunctionsHostImpl | undefined if (!rpcHost?._rpcGroup) return diff --git a/packages/devframe/src/node/context.ts b/packages/devframe/src/node/context.ts index 4aa20263..426786c1 100644 --- a/packages/devframe/src/node/context.ts +++ b/packages/devframe/src/node/context.ts @@ -4,7 +4,7 @@ import { diagnostics as rpcDiagnostics } from '../rpc/diagnostics' import { diagnostics as devframeDiagnostics } from './diagnostics' import { DevframeAgentHost } from './host-agent' import { DevframeDiagnosticsHost } from './host-diagnostics' -import { RpcFunctionsHost } from './host-functions' +import { RpcFunctionsHostImpl } from './host-functions' import { DevframeServicesHostImpl } from './host-services' import { DevframeViewHost } from './host-views' import { BUILTIN_AGENT_RPC } from './rpc' @@ -47,7 +47,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom scope: undefined!, } as unknown as DevframeNodeContext - const rpcHost = new RpcFunctionsHost(context) + const rpcHost = new RpcFunctionsHostImpl(context) const viewsHost = new DevframeViewHost(context) const diagnosticsHost = new DevframeDiagnosticsHost(context, [devframeDiagnostics, rpcDiagnostics]) context.rpc = rpcHost diff --git a/packages/devframe/src/node/host-functions.ts b/packages/devframe/src/node/host-functions.ts index 6c5b8e33..8eea3c4b 100644 --- a/packages/devframe/src/node/host-functions.ts +++ b/packages/devframe/src/node/host-functions.ts @@ -9,7 +9,25 @@ import { createRpcStreamingServerHost } from './rpc-streaming' const debugBroadcast = createDebug('devframe:rpc:broadcast') -export class RpcFunctionsHost extends RpcFunctionsCollectorBase implements RpcFunctionsHostType { +/** + * The public, structural shape of `ctx.rpc`. Re-exported from + * `devframe/types` so `import { RpcFunctionsHost } from 'devframe/node'` + * resolves to the exact same type as `DevframeNodeContext['rpc']` (and the + * `devframe` main entry) — free of the `@internal` implementation members + * carried by {@link RpcFunctionsHostImpl}. + */ +export type { RpcFunctionsHost } from 'devframe/types' + +/** + * Concrete implementation backing `ctx.rpc`. Internal: consumers should + * depend on the structural {@link RpcFunctionsHost} type, never this class. + * Its `@internal` members (`_rpcGroup`, `_asyncStorage`, + * `_emitSessionDisconnected`) are wired by `startHttpAndWs` and must not + * widen the public surface. + * + * @internal + */ +export class RpcFunctionsHostImpl extends RpcFunctionsCollectorBase implements RpcFunctionsHostType { /** * @internal */ diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index c904d4f2..69867f47 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -2,7 +2,10 @@ export * from './context' export * from './host-agent' export * from './host-diagnostics' -export * from './host-functions' +// `RpcFunctionsHostImpl` stays internal; expose only the structural +// `RpcFunctionsHost` type so consumers can type/cast `ctx.rpc` without +// pulling in the implementation's `@internal` members. +export type { RpcFunctionsHost } from './host-functions' export * from './host-h3' export * from './host-services' export * from './host-views' diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index c5b00998..bd60745a 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -4,7 +4,7 @@ import type { NodeAdapter } from 'crossws/adapters/node' import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' import type { Server as NodeHttpServer } from 'node:http' import type { DevframeAuthHandler } from './auth' -import type { RpcFunctionsHost } from './host-functions' +import type { RpcFunctionsHostImpl } from './host-functions' import { AsyncLocalStorage } from 'node:async_hooks' import { createServer } from 'node:http' import { createRpcServer } from 'devframe/rpc/server' @@ -143,7 +143,7 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise() diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 1b6ad6e2..64e82a9e 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -74,25 +74,14 @@ export declare class DevframeViewHost implements DevframeViewHost$1 { constructor(_: DevframeNodeContext); hostStatic(_: string, _: string): void; } -export declare class RpcFunctionsHost extends RpcFunctionsCollectorBase implements RpcFunctionsHost$1 { - _rpcGroup: BirpcGroup; - _asyncStorage: AsyncLocalStorage; - constructor(_: DevframeNodeContext); - sharedState: RpcSharedStateHost; - streaming: RpcStreamingHost; - _emitSessionDisconnected(_: DevframeNodeRpcSessionMeta): void; - invokeLocal>(_: T, ..._: Args): Promise>>; - broadcast>(_: RpcBroadcastOptions): Promise; - getCurrentRpcSession(): DevframeNodeRpcSession | undefined; -} // #endregion // #region Functions export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost; export declare function createHostContext(_: CreateHostContextOptions): Promise; export declare function createNodeSettings = Record>(_: DevframeNodeContext, _: string): DevframeSettings; -export declare function createRpcSharedStateServerHost(_: RpcFunctionsHost$1): RpcSharedStateHost; -export declare function createRpcStreamingServerHost(_: RpcFunctionsHost$1): RpcStreamingHost; +export declare function createRpcSharedStateServerHost(_: RpcFunctionsHost): RpcSharedStateHost; +export declare function createRpcStreamingServerHost(_: RpcFunctionsHost): RpcStreamingHost; export declare function createScopedNodeContext(_: DevframeNodeContext, _: NS): DevframeScopedNodeContext; export declare function createStorage(_: CreateStorageOptions): SharedState; export declare function formatHostForUrl(_: string): string; @@ -102,6 +91,7 @@ export declare function toDialableHost(_: string): string; // #endregion // #region Other +export { RpcFunctionsHost } export { StartedServer } export { startHttpAndWs } export { StartHttpAndWsOptions } diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index 11afe678..a580513e 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -16,7 +16,6 @@ export { DevframeViewHost } export { formatHostForUrl } export { isObject } export { normalizeHttpServerUrl } -export { RpcFunctionsHost } export { startHttpAndWs } export { toDialableHost } // #endregion \ No newline at end of file