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 →