jsonrpc: SDK serving surface as plugins (dsh-jsonrpc + dsh-jsonrpc-agent)
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* The SDK-facing stdio JSON-RPC server plugin: mounting it wires a
|
||||
* {@link JsonRpcLineTransport} over the process stdio and serves
|
||||
* {@link HarnessSdkServer} (`initialize` → `session/prompt`* → `shutdown`,
|
||||
* plus the `session.*`/`subagent.*` notifications) to an out-of-process SDK
|
||||
* client (e.g. the Python `deepseek_harness` package). The structured
|
||||
* SDK-client analogue of the `acp` bridge: a client-driver plugin over
|
||||
* `ctx.agents`, not a loop change and not a capability seam. Which process
|
||||
* actually serves this protocol is a `cordis.yml` decision — the tree that
|
||||
* loads this plugin IS the SDK server (the `dsh-jsonrpc-agent` bin boots such
|
||||
* a tree for the single-exe distribution; see
|
||||
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
*
|
||||
* stdout is the protocol: this plugin must run in a tree that loads NO stdout
|
||||
* logger (the console logger writes to stdout and would corrupt the JSON-RPC
|
||||
* frames). The guarantee is config-only — see the package README.
|
||||
*
|
||||
* Exit-lifecycle split: this plugin owns the PROTOCOL-level exit (the
|
||||
* `shutdown` request answers first, then the plugin disposes its own fiber and
|
||||
* exits 0 — see {@link apply}); process-level exits (stdin EOF, SIGTERM,
|
||||
* SIGINT) belong to the app bin (`dsh-jsonrpc-agent`), which disposes the
|
||||
* whole root context.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
|
||||
* export — the cordis Loader's `unwrapExports` does `exports.default ??
|
||||
* exports`, so a stray default would collapse the module to the bare `apply`
|
||||
* and silently drop `inject`/`name`/`Config` (see docs/postmortem/0001).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import Schema from 'schemastery'
|
||||
import { HarnessSdkServer } from './server.ts'
|
||||
import { JsonRpcLineTransport } from './transport.ts'
|
||||
|
||||
export * from './server.ts'
|
||||
export * from './transport.ts'
|
||||
|
||||
export const name = 'jsonrpc'
|
||||
// The server programs against the agent factory only: `agents` is read on
|
||||
// every `session/prompt` (get-or-create) and on `subagent/end` demux. The LLM
|
||||
// seam is deliberately NOT injected — `initialize` reads it opportunistically
|
||||
// via `ctx.get('llm')` (the topology-independent lookup for a non-injected
|
||||
// service, per packages/AGENTS.md) to decide whether to lazily mount the
|
||||
// DeepSeek adapter for the requested model.
|
||||
export const inject = ['agents']
|
||||
|
||||
/**
|
||||
* Plugin config. Every field is a runtime-only test seam — none is part of the
|
||||
* schemastery {@link Config}, so nothing here is settable from a `cordis.yml`
|
||||
* (production always serves the process stdio and exits via `process.exit`).
|
||||
*/
|
||||
export interface JsonRpcConfig {
|
||||
/**
|
||||
* Transport input override. Production omits this (the plugin reads
|
||||
* `process.stdin`); tests inject an in-memory `Readable` to drive the server
|
||||
* without a subprocess.
|
||||
*/
|
||||
input?: Readable
|
||||
/**
|
||||
* Transport output override. Production omits this (the plugin writes
|
||||
* `process.stdout` — the protocol channel); tests inject an in-memory
|
||||
* `Writable` to capture frames.
|
||||
*/
|
||||
output?: Writable
|
||||
/**
|
||||
* Process-exit override for the `shutdown` request path. Production omits
|
||||
* this (`process.exit`); tests inject a recorder so a driven shutdown does
|
||||
* not kill the test process.
|
||||
*/
|
||||
exit?: (code: number) => void
|
||||
}
|
||||
|
||||
export const Config: Schema<JsonRpcConfig> = Schema.object({})
|
||||
|
||||
/**
|
||||
* Mount the SDK server on the process stdio: build the line transport and
|
||||
* {@link HarnessSdkServer}, dispatch incoming requests, and start reading
|
||||
* frames. Disposal is an effect: disposing this plugin's fiber runs
|
||||
* `server.shutdown()` (disposes every SDK-created agent to quiescence and
|
||||
* detaches the event subscriptions) and `transport.close()`.
|
||||
*
|
||||
* The `shutdown` request's process-exit semantics live HERE, because the
|
||||
* plugin owns the server and transport: the request is answered first
|
||||
* (`setImmediate` lets the response frame flush), then the plugin disposes its
|
||||
* OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the
|
||||
* request's `server.shutdown()` already brought every SDK-created agent to
|
||||
* quiescence (their session logs are flushed by the awaited agent-handle
|
||||
* disposes), the fiber's effect disposer re-runs the idempotent shutdown and
|
||||
* closes the transport, and the process exit that follows IS the teardown of
|
||||
* the rest of the tree (the bin's EOF/signal handlers own root-context
|
||||
* disposal for the process-level exits).
|
||||
*/
|
||||
export function apply(ctx: Context, config: JsonRpcConfig): void {
|
||||
// Capture the fiber handle NOW, during apply(): the shutdown path runs LATER,
|
||||
// from the transport's read loop, and must dispose exactly this plugin's
|
||||
// fiber (cf. the injection-scope capture note in the acp bridge).
|
||||
const fiber = ctx.fiber
|
||||
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
|
||||
const input = config.input ?? process.stdin
|
||||
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
|
||||
const output = config.output ?? process.stdout
|
||||
/* v8 ignore next -- production exit wiring; tests always inject the runtime seams */
|
||||
const exit = config.exit ?? ((code: number): void => { process.exit(code) })
|
||||
|
||||
const transport = new JsonRpcLineTransport(input, output)
|
||||
const server = new HarnessSdkServer(ctx, transport)
|
||||
|
||||
// The shutdown-request exit path, exactly once (a second `shutdown` frame
|
||||
// racing the dispose must not re-enter). `exit(0)` runs even if the dispose
|
||||
// throws — the client was already answered, so exiting is the honest outcome.
|
||||
let exiting = false
|
||||
const disposeAndExit = async (): Promise<void> => {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
try {
|
||||
await fiber.dispose()
|
||||
} finally {
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
transport.onRequest(async (method, params) => {
|
||||
const result = await server.handleRequest(method, params)
|
||||
if (method === 'shutdown') {
|
||||
// Answer the request first (setImmediate lets the response frame
|
||||
// flush), then dispose this plugin's fiber and exit 0 (see apply's doc).
|
||||
setImmediate(() => { void disposeAndExit() })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
transport.start()
|
||||
return async () => {
|
||||
await server.shutdown()
|
||||
transport.close()
|
||||
}
|
||||
}, 'jsonrpc.serve')
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* `HarnessSdkServer`: the JSON-RPC method surface the `dsh-jsonrpc` plugin
|
||||
* serves to out-of-process SDK clients (e.g. the Python `deepseek_harness`
|
||||
* package). Requests: `initialize` → `session/prompt`* → `shutdown`.
|
||||
* Notifications pushed to the host: `session.event` (every durable session
|
||||
* event, verbatim), `session.finished` (per prompt turn settle),
|
||||
* `subagent.started` / `subagent.finished` (child-session lineage and run
|
||||
* outcomes). The server owns only the SDK-facing session map — the harness
|
||||
* itself is the context the plugin mounts in; plugins, persistence, and
|
||||
* the LLM adapter set all come from the external `cordis.yml`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc/server
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import type { JsonRpcTransportPeer } from './transport.ts'
|
||||
|
||||
/** Parameters of the `initialize` request (once per process, before any prompt). */
|
||||
export interface InitializeParams {
|
||||
/** Working directory recorded on every SDK-created session's header. */
|
||||
cwd: string
|
||||
/** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */
|
||||
model: string
|
||||
/** Accepted for SDK wire compatibility; unused — persistence roots come from the `cordis.yml`. */
|
||||
sessionRoot?: string
|
||||
/**
|
||||
* Accepted for SDK wire compatibility; currently NOT applied — the deployment
|
||||
* persona comes from the `cordis.yml` system-prompt config. TODO(jsonrpc):
|
||||
* map this onto a per-runtime system-prompt section once a per-agent override
|
||||
* seam exists.
|
||||
*/
|
||||
systemPrompt?: string
|
||||
/** Accepted for SDK wire compatibility; unused diagnostic client identity. */
|
||||
clientInfo?: { name?: string; version?: string }
|
||||
}
|
||||
|
||||
/** Result of the `initialize` request: the server's identity for the SDK handshake. */
|
||||
export interface InitializeResult {
|
||||
/** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */
|
||||
serverInfo: { name: string; version: string }
|
||||
}
|
||||
|
||||
/** Parameters of a `session/prompt` request (one user turn on one SDK session). */
|
||||
export interface SessionPromptParams {
|
||||
/** The SDK-side session id; an unknown id lazily creates the agent+session pair. */
|
||||
sessionId: string
|
||||
/** The prompt content blocks, sent verbatim as the user message. */
|
||||
contentBlocks: ContentBlock[]
|
||||
/** Accepted for SDK wire compatibility; unused — profiles are not a harness concept. */
|
||||
profile?: string
|
||||
}
|
||||
|
||||
/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */
|
||||
export interface SessionPromptResult {
|
||||
/** Always `true`; the turn outcome is the paired `session.finished` notification. */
|
||||
accepted: true
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
handle: AgentHandle
|
||||
lastTurnEnd: TurnEndReason | undefined
|
||||
}
|
||||
|
||||
interface SubagentRecord {
|
||||
childSessionId: string
|
||||
parentSessionId: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK server over a booted harness context. Constructing it subscribes to
|
||||
* the context's `session/event`, `session/created`, `agent/created`, and
|
||||
* `subagent/end` events and forwards them to the host as notifications; the
|
||||
* subscriptions live until {@link shutdown}. One instance serves one transport
|
||||
* peer for the process lifetime — there is no re-`initialize`.
|
||||
*/
|
||||
export class HarnessSdkServer {
|
||||
private cwd = process.cwd()
|
||||
private model = 'deepseek'
|
||||
private llmFiber: { dispose(): Promise<void> } | undefined
|
||||
private readonly sessions = new Map<string, SessionRecord>()
|
||||
private readonly subagentSessions = new Map<string, SubagentRecord>()
|
||||
private readonly disposers: (() => void)[] = []
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly transport: JsonRpcTransportPeer,
|
||||
) {
|
||||
this.disposers.push(ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'turn/end') {
|
||||
const rec = this.sessions.get(String(session.id))
|
||||
if (rec) rec.lastTurnEnd = event.data.reason
|
||||
}
|
||||
this.transport.notify('session.event', { sessionId: String(session.id), event })
|
||||
}))
|
||||
this.disposers.push(ctx.on('session/created', (session) => {
|
||||
const parentSession = session.header.parentSession
|
||||
if (parentSession === undefined) return
|
||||
this.transport.notify('subagent.started', {
|
||||
parentSessionId: String(parentSession),
|
||||
childSessionId: String(session.id),
|
||||
})
|
||||
}))
|
||||
// Cache agent → session lineage on creation: by the time `subagent/end`
|
||||
// fires the child agent may already be disposed and gone from the registry.
|
||||
this.disposers.push(ctx.on('agent/created', (agent) => {
|
||||
this.subagentSessions.set(String(agent.id), {
|
||||
childSessionId: String(agent.session.id),
|
||||
parentSessionId: agent.session.header.parentSession === undefined
|
||||
? undefined
|
||||
: String(agent.session.header.parentSession),
|
||||
})
|
||||
}))
|
||||
this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => {
|
||||
const rec = this.subagentSessions.get(String(info.id))
|
||||
const agent = this.ctx.agents.get(info.id)
|
||||
const childSessionId = rec?.childSessionId ?? (agent === undefined ? undefined : String(agent.session.id))
|
||||
const parentSessionId = rec?.parentSessionId ?? (
|
||||
agent?.session.header.parentSession === undefined ? undefined : String(agent.session.header.parentSession)
|
||||
)
|
||||
if (childSessionId === undefined) return
|
||||
this.transport.notify('subagent.finished', {
|
||||
provider: info.provider,
|
||||
agentId: String(info.id),
|
||||
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
||||
childSessionId,
|
||||
status: info.stopReason === 'completed' || info.stopReason === 'max-tokens' ? 'ok' : 'error',
|
||||
stopReason: info.stopReason,
|
||||
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `initialize`: record the SDK deployment facts (cwd, model) and, when
|
||||
* no registered adapter serves `params.model`, mount the DeepSeek adapter for
|
||||
* it (credentials from `$DEEPSEEK_API_KEY`/`$DEEPSEEK_BASE_URL`) — a config
|
||||
* that already registered an adapter for the model wins.
|
||||
* @param params - the SDK handshake parameters.
|
||||
* @returns the server identity for the handshake.
|
||||
*/
|
||||
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
||||
this.cwd = params.cwd
|
||||
this.model = params.model
|
||||
if (!this.llmFiber && !this.hasAdapterFor(this.model)) {
|
||||
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { models: [this.model] })
|
||||
}
|
||||
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `session/prompt`: get-or-create the session's agent, send the
|
||||
* content as the user message, await turn settle (quiescence), then notify
|
||||
* `session.finished` with the settled turn's outcome.
|
||||
* @param params - the target session id and prompt content.
|
||||
* @returns `{ accepted: true }` after the turn settled.
|
||||
*/
|
||||
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
|
||||
const rec = this.getOrCreateSession(params.sessionId)
|
||||
rec.lastTurnEnd = undefined
|
||||
rec.handle.agent.send(params.contentBlocks)
|
||||
await rec.handle.agent.whenIdle()
|
||||
const status = this.finishedStatus(rec.lastTurnEnd)
|
||||
this.transport.notify('session.finished', {
|
||||
sessionId: params.sessionId,
|
||||
status,
|
||||
reason: rec.lastTurnEnd,
|
||||
})
|
||||
return { accepted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `shutdown`: dispose every SDK-created agent handle (awaiting loop
|
||||
* quiescence), unmount the adapter fiber this server mounted (if any), and
|
||||
* detach the event subscriptions. The CONTEXT stays up — the bin disposes it
|
||||
* as part of process exit.
|
||||
* @returns an empty object (the JSON-RPC result).
|
||||
*/
|
||||
async shutdown(): Promise<Record<string, never>> {
|
||||
const records = [...this.sessions.values()]
|
||||
this.sessions.clear()
|
||||
await Promise.all(records.map(rec => rec.handle.dispose()))
|
||||
await this.llmFiber?.dispose()
|
||||
this.llmFiber = undefined
|
||||
while (this.disposers.length > 0) this.disposers.pop()?.()
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a
|
||||
* JSON-RPC error response) on an unknown method.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the raw params object from the wire.
|
||||
* @returns the handler's result, to be serialized as the response.
|
||||
*/
|
||||
async handleRequest(method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
return this.initialize(params as unknown as InitializeParams)
|
||||
case 'session/prompt':
|
||||
return this.prompt(params as unknown as SessionPromptParams)
|
||||
case 'shutdown':
|
||||
return this.shutdown()
|
||||
default:
|
||||
throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateSession(sessionId: string): SessionRecord {
|
||||
const existing = this.sessions.get(sessionId)
|
||||
if (existing) return existing
|
||||
const handle = this.ctx.agents.create({
|
||||
agentId: AgentId(sessionId),
|
||||
sessionId: SessionId(sessionId),
|
||||
meta: { cwd: this.cwd },
|
||||
agentOptions: { model: this.model },
|
||||
})
|
||||
const rec: SessionRecord = { handle, lastTurnEnd: undefined }
|
||||
this.sessions.set(sessionId, rec)
|
||||
return rec
|
||||
}
|
||||
|
||||
private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
|
||||
if (!reason) return 'error'
|
||||
return reason.kind === 'completed' || reason.kind === 'max-tokens' ? 'ok' : 'error'
|
||||
}
|
||||
|
||||
private hasAdapterFor(model: string): boolean {
|
||||
return this.ctx.get('llm')?.models().includes(model) ?? false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Newline-delimited JSON-RPC 2.0 transport over a byte stream pair (the SDK
|
||||
* server's stdio channel). One JSON frame per line; a frame with `id`+`method`
|
||||
* is an incoming request, `id` alone matches a pending outgoing request, and
|
||||
* `method` alone is a notification. Malformed lines are ignored (a resilient
|
||||
* wire reader, not a validator); handler failures become JSON-RPC error
|
||||
* responses, never a crashed transport.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc/transport
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
|
||||
type JsonRpcId = string | number
|
||||
type RequestHandler = (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
|
||||
|
||||
/**
|
||||
* The outbound half of a JSON-RPC peer — what {@link HarnessSdkServer} needs
|
||||
* to talk BACK to the host: awaited `request`s and fire-and-forget `notify`s.
|
||||
* Narrow on purpose so tests substitute a recording fake without a stream pair.
|
||||
*/
|
||||
export interface JsonRpcTransportPeer {
|
||||
/**
|
||||
* Send a request to the remote peer and await its response.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the request parameters object.
|
||||
* @returns the remote peer's `result`; rejects on a JSON-RPC `error`
|
||||
* response, a write failure, or transport/input closure.
|
||||
*/
|
||||
request(method: string, params: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
* Send a notification (no response expected). An omitted `params` sends no
|
||||
* `params` member at all.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the optional notification parameters object.
|
||||
*/
|
||||
notify(method: string, params?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-delimited JSON-RPC 2.0 endpoint over a `Readable`/`Writable` pair.
|
||||
* Inert until {@link start} attaches the input listeners; {@link close}
|
||||
* detaches them and rejects every pending outgoing request (dispose-safe: the
|
||||
* streams themselves are not destroyed — the caller owns them). Incoming
|
||||
* requests are dispatched to the single {@link onRequest} handler (a missing
|
||||
* handler answers `-32601 method not found`; a throwing handler answers
|
||||
* `-32603` with the message); incoming notifications go to {@link
|
||||
* onNotification} and are dropped without one.
|
||||
*/
|
||||
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
private buffer = ''
|
||||
private started = false
|
||||
private requestHandler: RequestHandler | undefined
|
||||
private notificationHandler: NotificationHandler | undefined
|
||||
private readonly pending = new Map<JsonRpcId, PendingRequest>()
|
||||
|
||||
constructor(
|
||||
private readonly input: Readable,
|
||||
private readonly output: Writable,
|
||||
) {}
|
||||
|
||||
/** Attach the input listeners and begin reading frames. Idempotent. */
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.input.on('data', this.onData)
|
||||
this.input.on('error', this.onInputError)
|
||||
this.input.on('end', this.onInputEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the input listeners and reject every pending outgoing request with
|
||||
* "JSON-RPC transport closed". Safe to call without a prior {@link start}.
|
||||
*/
|
||||
close(): void {
|
||||
this.input.off('data', this.onData)
|
||||
this.input.off('error', this.onInputError)
|
||||
this.input.off('end', this.onInputEnd)
|
||||
this.failPending(new Error('JSON-RPC transport closed'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Install THE handler for incoming requests (a later call replaces it).
|
||||
* @param handler - resolves to the response `result`; a rejection becomes a
|
||||
* `-32603` error response carrying the message.
|
||||
*/
|
||||
onRequest(handler: RequestHandler): void {
|
||||
this.requestHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Install THE handler for incoming notifications (a later call replaces it).
|
||||
* @param handler - invoked per notification with the method and normalized
|
||||
* params object.
|
||||
*/
|
||||
onNotification(handler: NotificationHandler): void {
|
||||
this.notificationHandler = handler
|
||||
}
|
||||
|
||||
request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
const id = `req_${randomUUID().replaceAll('-', '')}`
|
||||
const message = { jsonrpc: '2.0', id, method, params }
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
try {
|
||||
this.write(message)
|
||||
} catch (error) {
|
||||
this.pending.delete(id)
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
notify(method: string, params?: Record<string, unknown>): void {
|
||||
this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params })
|
||||
}
|
||||
|
||||
private readonly onData = (chunk: Buffer | string): void => {
|
||||
this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
||||
for (;;) {
|
||||
const newline = this.buffer.indexOf('\n')
|
||||
if (newline < 0) break
|
||||
const line = this.buffer.slice(0, newline).trim()
|
||||
this.buffer = this.buffer.slice(newline + 1)
|
||||
if (!line) continue
|
||||
void this.handleLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onInputError = (error: Error): void => {
|
||||
this.failPending(error)
|
||||
}
|
||||
|
||||
private readonly onInputEnd = (): void => {
|
||||
this.failPending(new Error('JSON-RPC input closed'))
|
||||
}
|
||||
|
||||
private async handleLine(line: string): Promise<void> {
|
||||
let message: unknown
|
||||
try {
|
||||
message = JSON.parse(line)
|
||||
} catch {
|
||||
// Swallows ONLY JSON.parse syntax errors: a malformed wire line is a
|
||||
// peer bug this resilient reader skips; nothing else runs in the try.
|
||||
return
|
||||
}
|
||||
if (!message || typeof message !== 'object') return
|
||||
const frame = message as Record<string, unknown>
|
||||
const id = frame.id
|
||||
const method = frame.method
|
||||
if ((typeof id === 'string' || typeof id === 'number') && typeof method === 'string') {
|
||||
await this.handleIncomingRequest(id, method, objectParams(frame.params))
|
||||
return
|
||||
}
|
||||
if (typeof id === 'string' || typeof id === 'number') {
|
||||
this.handleIncomingResponse(id, frame)
|
||||
return
|
||||
}
|
||||
if (typeof method === 'string') {
|
||||
this.notificationHandler?.(method, objectParams(frame.params))
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingRequest(id: JsonRpcId, method: string, params: Record<string, unknown>): Promise<void> {
|
||||
const handler = this.requestHandler
|
||||
if (!handler) {
|
||||
this.writeError(id, -32601, `method not found: ${method}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await handler(method, params)
|
||||
this.write({ jsonrpc: '2.0', id, result })
|
||||
} catch (error) {
|
||||
this.writeError(id, -32603, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private handleIncomingResponse(id: JsonRpcId, frame: Record<string, unknown>): void {
|
||||
const pending = this.pending.get(id)
|
||||
if (!pending) return
|
||||
this.pending.delete(id)
|
||||
if (frame.error && typeof frame.error === 'object') {
|
||||
const error = frame.error as Record<string, unknown>
|
||||
pending.reject(new Error(typeof error.message === 'string' ? error.message : 'JSON-RPC error'))
|
||||
return
|
||||
}
|
||||
pending.resolve(frame.result)
|
||||
}
|
||||
|
||||
private writeError(id: JsonRpcId, code: number, message: string): void {
|
||||
this.write({ jsonrpc: '2.0', id, error: { code, message } })
|
||||
}
|
||||
|
||||
private write(message: Record<string, unknown>): void {
|
||||
this.output.write(`${JSON.stringify(message)}\n`)
|
||||
}
|
||||
|
||||
private failPending(error: Error): void {
|
||||
const pending = [...this.pending.values()]
|
||||
this.pending.clear()
|
||||
for (const waiter of pending) waiter.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize JSON-RPC `params` to a plain object (arrays and scalars collapse to `{}`). */
|
||||
function objectParams(params: unknown): Record<string, unknown> {
|
||||
return params && typeof params === 'object' && !Array.isArray(params) ? params as Record<string, unknown> : {}
|
||||
}
|
||||
Reference in New Issue
Block a user