Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions packages/devframe/src/node/__tests__/host-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
46 changes: 34 additions & 12 deletions packages/devframe/src/node/__tests__/host-functions.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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({
Expand Down Expand Up @@ -131,15 +132,15 @@ 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: [],
})).resolves.toBeUndefined()
})

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: [],
Expand All @@ -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',
Expand All @@ -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<DevframeNodeContext['rpc']>().toEqualTypeOf<RpcFunctionsHost>()
expectTypeOf<DevframeNodeContext['rpc']>().toMatchTypeOf<RpcFunctionsHost>()
})

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
})
})
})
6 changes: 3 additions & 3 deletions packages/devframe/src/node/__tests__/rpc-streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
}

async function bootHost(): Promise<Harness> {
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<any>()

Expand Down
4 changes: 2 additions & 2 deletions packages/devframe/src/node/auth/revoke.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -13,7 +13,7 @@ export async function revokeActiveConnectionsForToken(
context: DevframeNodeContext,
token: string,
): Promise<void> {
const rpcHost = context.rpc as unknown as RpcFunctionsHost | undefined
const rpcHost = context.rpc as unknown as RpcFunctionsHostImpl | undefined
if (!rpcHost?._rpcGroup)
return

Expand Down
4 changes: 2 additions & 2 deletions packages/devframe/src/node/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion packages/devframe/src/node/host-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,25 @@ import { createRpcStreamingServerHost } from './rpc-streaming'

const debugBroadcast = createDebug('devframe:rpc:broadcast')

export class RpcFunctionsHost extends RpcFunctionsCollectorBase<DevframeRpcServerFunctions, DevframeNodeContext> 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<DevframeRpcServerFunctions, DevframeNodeContext> implements RpcFunctionsHostType {
/**
* @internal
*/
Expand Down
5 changes: 4 additions & 1 deletion packages/devframe/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions packages/devframe/src/node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -143,7 +143,7 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<St
// otherwise we own a fresh one bound to `app`.
const ownsHttpServer = !options.server
const httpServer = options.server ?? createServer(toNodeHandler(app))
const rpcHost = context.rpc as unknown as RpcFunctionsHost
const rpcHost = context.rpc as unknown as RpcFunctionsHostImpl

const asyncStorage = new AsyncLocalStorage<DevframeNodeRpcSession>()

Expand Down
16 changes: 3 additions & 13 deletions tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,25 +74,14 @@ export declare class DevframeViewHost implements DevframeViewHost$1 {
constructor(_: DevframeNodeContext);
hostStatic(_: string, _: string): void;
}
export declare class RpcFunctionsHost extends RpcFunctionsCollectorBase<DevframeRpcServerFunctions, DevframeNodeContext> implements RpcFunctionsHost$1 {
_rpcGroup: BirpcGroup<DevframeRpcClientFunctions, DevframeRpcServerFunctions, false>;
_asyncStorage: AsyncLocalStorage<DevframeNodeRpcSession>;
constructor(_: DevframeNodeContext);
sharedState: RpcSharedStateHost;
streaming: RpcStreamingHost;
_emitSessionDisconnected(_: DevframeNodeRpcSessionMeta): void;
invokeLocal<T extends keyof DevframeRpcServerFunctions, Args extends Parameters<DevframeRpcServerFunctions[T]>>(_: T, ..._: Args): Promise<Awaited<ReturnType<DevframeRpcServerFunctions[T]>>>;
broadcast<T extends keyof DevframeRpcClientFunctions, Args extends Parameters<DevframeRpcClientFunctions[T]>>(_: RpcBroadcastOptions<T, Args>): Promise<void>;
getCurrentRpcSession(): DevframeNodeRpcSession | undefined;
}
// #endregion

// #region Functions
export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost;
export declare function createHostContext(_: CreateHostContextOptions): Promise<DevframeNodeContext>;
export declare function createNodeSettings<T extends Record<string, any> = Record<string, any>>(_: DevframeNodeContext, _: string): DevframeSettings<T>;
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<NS extends string = string>(_: DevframeNodeContext, _: NS): DevframeScopedNodeContext<NS>;
export declare function createStorage<T extends object>(_: CreateStorageOptions<T>): SharedState<T>;
export declare function formatHostForUrl(_: string): string;
Expand All @@ -102,6 +91,7 @@ export declare function toDialableHost(_: string): string;
// #endregion

// #region Other
export { RpcFunctionsHost }
export { StartedServer }
export { startHttpAndWs }
export { StartHttpAndWsOptions }
Expand Down
1 change: 0 additions & 1 deletion tests/__snapshots__/tsnapi/devframe/node.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export { DevframeViewHost }
export { formatHostForUrl }
export { isObject }
export { normalizeHttpServerUrl }
export { RpcFunctionsHost }
export { startHttpAndWs }
export { toDialableHost }
// #endregion
Loading