Skip to content

JSON-RPC Request Builder — TypeScript source

Build valid JSON-RPC 2.0 requests, notifications, success responses, and error responses, plus batch arrays. Validate message structure.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Pure JSON-RPC 2.0 builder - no React, no DOM, deterministic.
// Builds requests, notifications, success/error responses, and batches.
// Never throws.

export const JSONRPC_VERSION = '2.0';

export type RpcId = string | number | null;

export interface BuildOptions {
  indent?: number;
}

interface Outcome {
  ok: boolean;
  json: string;
  error: string | null;
}

export const STANDARD_ERRORS: Record<number, { message: string }> = {
  [-32700]: { message: 'Parse error' },
  [-32600]: { message: 'Invalid Request' },
  [-32601]: { message: 'Method not found' },
  [-32602]: { message: 'Invalid params' },
  [-32603]: { message: 'Internal error' },
  [-32000]: { message: 'Server error' },
};

function isNonEmptyString(v: unknown): v is string {
  return typeof v === 'string' && v.length > 0;
}

function safeStringify(obj: unknown, indent?: number): string {
  return JSON.stringify(obj, null, indent ?? 0);
}

export function buildRequest(
  method: string,
  params?: unknown,
  id: RpcId = 1,
  opts: BuildOptions = {},
): Outcome {
  if (!isNonEmptyString(method)) return { ok: false, json: '', error: 'method must be a non-empty string' };
  const obj: Record<string, unknown> = { jsonrpc: JSONRPC_VERSION, method };
  if (params !== undefined) obj.params = params;
  obj.id = id;
  return { ok: true, json: safeStringify(obj, opts.indent), error: null };
}

export function buildNotification(
  method: string,
  params?: unknown,
  opts: BuildOptions = {},
): Outcome {
  if (!isNonEmptyString(method)) return { ok: false, json: '', error: 'method must be a non-empty string' };
  const obj: Record<string, unknown> = { jsonrpc: JSONRPC_VERSION, method };
  if (params !== undefined) obj.params = params;
  return { ok: true, json: safeStringify(obj, opts.indent), error: null };
}

export function buildSuccessResponse(
  id: RpcId,
  result: unknown,
  opts: BuildOptions = {},
): Outcome {
  const obj = { jsonrpc: JSONRPC_VERSION, result, id };
  return { ok: true, json: safeStringify(obj, opts.indent), error: null };
}

export function buildErrorResponse(
  id: RpcId,
  code: number,
  message?: string,
  data?: unknown,
  opts: BuildOptions = {},
): Outcome {
  const msg = message ?? STANDARD_ERRORS[code]?.message ?? 'Error';
  const error: Record<string, unknown> = { code, message: msg };
  if (data !== undefined) error.data = data;
  const obj = { jsonrpc: JSONRPC_VERSION, error, id };
  return { ok: true, json: safeStringify(obj, opts.indent), error: null };
}

export function buildBatch(messages: unknown[], opts: BuildOptions = {}): Outcome {
  if (!Array.isArray(messages) || messages.length === 0) {
    return { ok: false, json: '', error: 'batch must be a non-empty array' };
  }
  return { ok: true, json: safeStringify(messages, opts.indent), error: null };
}

export function validateRpc(obj: unknown): { valid: boolean; errors: string[] } {
  const errors: string[] = [];
  if (typeof obj !== 'object' || obj === null) {
    return { valid: false, errors: ['Not an object.'] };
  }
  const o = obj as Record<string, unknown>;
  if (o.jsonrpc !== JSONRPC_VERSION) errors.push('jsonrpc must be "2.0".');
  if ('method' in o && typeof o.method !== 'string') errors.push('method must be a string.');
  if ('result' in o && 'error' in o) errors.push('cannot have both result and error.');
  if (!('method' in o) && !('result' in o) && !('error' in o)) {
    errors.push('must have method, result, or error.');
  }
  return { valid: errors.length === 0, errors };
}

// --- Builder-family surface (editor config, lenient import parse, issue
// validation, URL codecs). Everything above stays the wire-format layer;
// everything below is the editor state layer the island binds to.

export type JsonRpcMsgType = 'request' | 'notification' | 'result' | 'error';

/** Editor config - every free-text field is raw text; coercion is pure below. */
export interface JsonRpcConfig {
  type: JsonRpcMsgType;
  method: string;
  params: string;
  id: string;
  result: string;
  code: string;
}

export type JsonRpcIssueCode =
  | 'jsonrpc-version'
  | 'method-missing'
  | 'id-null-request'
  | 'params-type';

export interface JsonRpcIssue {
  code: JsonRpcIssueCode;
  severity: 'warn' | 'info';
  value?: string;
}

/** '' -> null (JSON null id), finite numeric text -> number, else the string. */
export function coerceId(idText: string): RpcId {
  const t = (idText ?? '').trim();
  if (t === '') return null;
  const n = Number(t);
  return Number.isFinite(n) ? n : t;
}

/** '' -> undefined (omit the member); valid JSON -> the parsed value; invalid -> the raw string. */
export function coerceJsonText(text: string): unknown {
  const t = (text ?? '').trim();
  if (t === '') return undefined;
  try {
    return JSON.parse(t) as unknown;
  } catch {
    return t; // not valid JSON - keep the raw string so the edit is never lost
  }
}

/** '' or non-numeric -> -32603 (internal error); numeric text -> the number (0 kept). */
export function coerceCode(codeText: string): number {
  const t = (codeText ?? '').trim();
  if (t === '') return -32603;
  const n = Number(t);
  return Number.isFinite(n) ? n : -32603;
}

/** Shape recovered by the import roundtrip: what kind of message + its members. */
export interface ParsedJsonRpc {
  kind: JsonRpcMsgType;
  method?: string;
  params?: unknown;
  id?: RpcId;
  result?: unknown;
  errorCode?: number;
}

/**
 * Tolerant parse of pasted text into a JSON-RPC message. Returns null when the
 * text is not valid JSON, not an object (arrays are batches - out of scope),
 * or carries none of method / error / result. Never throws.
 */
export function parseJsonRpc(text: string): ParsedJsonRpc | null {
  let raw: unknown;
  try {
    raw = JSON.parse((text ?? '').trim());
  } catch {
    return null;
  }
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
  const o = raw as Record<string, unknown>;
  if (typeof o.method === 'string') {
    return o.id === undefined
      ? { kind: 'notification', method: o.method, params: o.params }
      : { kind: 'request', method: o.method, params: o.params, id: o.id as RpcId };
  }
  if (typeof o.error === 'object' && o.error !== null) {
    const e = o.error as Record<string, unknown>;
    return {
      kind: 'error',
      errorCode: typeof e.code === 'number' ? e.code : undefined,
      id: o.id as RpcId,
    };
  }
  if ('result' in o) {
    return { kind: 'result', result: o.result, id: o.id as RpcId };
  }
  return null;
}

export interface BuildMessageResult {
  obj: Record<string, unknown> | null;
  json: string;
  error: string | null;
}

/** Build the live output from the editor config. `obj` is null when the build fails. */
export function buildJsonRpcMessage(config: JsonRpcConfig, opts: BuildOptions = {}): BuildMessageResult {
  const params = coerceJsonText(config.params);
  const rpcId = coerceId(config.id);
  let r: Outcome;
  switch (config.type) {
    case 'request':
      r = buildRequest(config.method, params, rpcId, opts);
      break;
    case 'notification':
      r = buildNotification(config.method, params, opts);
      break;
    case 'result':
      r = buildSuccessResponse(rpcId, coerceJsonText(config.result), opts);
      break;
    default:
      r = buildErrorResponse(rpcId, coerceCode(config.code), undefined, undefined, opts);
      break;
  }
  if (!r.ok) return { obj: null, json: '', error: r.error };
  return { obj: JSON.parse(r.json) as Record<string, unknown>, json: r.json, error: null };
}

/**
 * Issue validation over a BUILT or parsed message object (not raw text):
 * - jsonrpc missing or not "2.0" -> warn
 * - no method, result, or error member -> warn (method missing)
 * - method message whose id is null -> warn (the peer cannot reply)
 * - params present but not an array/object (null included) -> info
 */
export function validateJsonRpc(obj: unknown): JsonRpcIssue[] {
  const issues: JsonRpcIssue[] = [];
  if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return issues;
  const o = obj as Record<string, unknown>;
  if (o.jsonrpc !== JSONRPC_VERSION) {
    issues.push({ code: 'jsonrpc-version', severity: 'warn', value: String(o.jsonrpc) });
  }
  const hasMethod = typeof o.method === 'string';
  if (!hasMethod && !('result' in o) && !('error' in o)) {
    issues.push({ code: 'method-missing', severity: 'warn' });
  }
  if (hasMethod && o.id === null) {
    issues.push({ code: 'id-null-request', severity: 'warn' });
  }
  if (o.params !== undefined && (typeof o.params !== 'object' || o.params === null)) {
    issues.push({ code: 'params-type', severity: 'info', value: JSON.stringify(o.params) });
  }
  return issues;
}

// URL codecs for shareable state. Every component is encodeURIComponent'd
// BEFORE joining, so the '&'/ '=' of the query grammar stay unambiguous.
// Keys: t=type, m=method, p=params, i=id, r=result, c=code; empty components
// are omitted so the common request share stays short.

const enc = (s: string) => encodeURIComponent(s);
const dec = (s: string): string => {
  try {
    return decodeURIComponent(s);
  } catch {
    return s; // malformed escape - keep verbatim rather than throw
  }
};

export function toQuery(config: JsonRpcConfig): string {
  const p = new URLSearchParams();
  p.set('t', enc(config.type));
  p.set('m', enc(config.method));
  if (config.params !== '') p.set('p', enc(config.params));
  if (config.id !== '') p.set('i', enc(config.id));
  if (config.result !== '') p.set('r', enc(config.result));
  if (config.code !== '') p.set('c', enc(config.code));
  return p.toString();
}

export function fromQuery(params: URLSearchParams): JsonRpcConfig | null {
  const t = params.get('t');
  if (t !== 'request' && t !== 'notification' && t !== 'result' && t !== 'error') return null;
  return {
    type: t,
    method: dec(params.get('m') ?? ''),
    params: dec(params.get('p') ?? ''),
    id: dec(params.get('i') ?? ''),
    result: dec(params.get('r') ?? ''),
    code: dec(params.get('c') ?? ''),
  };
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →