All files / ocap-jsonrpc-vat/src json-rpc.ts

100% Statements 40/40
100% Branches 29/29
100% Functions 6/6
100% Lines 38/38

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207                    3x             3x                                                         3x                                               24x 24x 24x                                       45x 15x 15x 6x   9x 9x 9x 2x         7x   30x 23x   7x 3x 3x 6x   3x   4x                         43x                                                               67x 19x   48x 9x   43x               5x         38x                     8x         30x 12x 12x 21x   6x   18x    
/**
 * Wire-shape types and walker helpers for the ocap JSON-RPC vat's
 * line-delimited JSON-RPC 2.0 protocol.
 *
 * Object references are named via the sigil convention `"@@NAME"` (NAME
 * one or more alphanumeric characters). The mediator assigns names of the
 * form `j<n>`; other allocation schemes remain compatible with the walker.
 */
 
/** Full sigil string prefix (two `@`). */
export const MARKER_PREFIX = '@@';
 
/**
 * Match a whole string that consists solely of the sigil plus an
 * alphanumeric name. Anchored deliberately: an embedded `@@x` is
 * plain data.
 */
export const MARKER_PATTERN = /^@@([A-Za-z0-9]+)$/u;
 
export type JsonRpcId = number | string | null;
 
export type JsonRpcRequest = {
  jsonrpc: '2.0';
  id: JsonRpcId;
  method: string;
  params?: unknown;
};
 
export type JsonRpcSuccessResponse = {
  jsonrpc: '2.0';
  id: JsonRpcId;
  result: unknown;
};
 
export type JsonRpcErrorResponse = {
  jsonrpc: '2.0';
  id: JsonRpcId;
  error: { code: number; message: string; data?: unknown };
};
 
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
 
/**
 * Standard JSON-RPC 2.0 error codes plus a mediator-specific application
 * code in the reserved `-32000..-32099` range.
 */
export const JSON_RPC_ERROR = {
  PARSE_ERROR: -32700,
  INVALID_REQUEST: -32600,
  METHOD_NOT_FOUND: -32601,
  INVALID_PARAMS: -32602,
  INTERNAL_ERROR: -32603,
  APPLICATION_ERROR: -32000,
} as const;
 
/**
 * Thrown from inside the mediator's request handlers to signal the
 * intended JSON-RPC error code and message.
 */
export class BridgeRpcError extends Error {
  readonly code: number;
 
  readonly data?: unknown;
 
  /**
   * @param code - JSON-RPC error code to report (see {@link JSON_RPC_ERROR}).
   * @param message - Human-readable error description.
   * @param data - Optional additional error data to attach.
   */
  constructor(code: number, message: string, data?: unknown) {
    super(message);
    this.code = code;
    this.data = data;
  }
}
 
/**
 * Walk `value`, replacing every `"@@NAME"` marker string with
 * `resolve(name)`. Descends into plain arrays and record-like objects.
 *
 * @param value - The value to walk.
 * @param resolve - Callback that turns a NAME into a live reference.
 * If it returns `undefined` the walker throws — an unknown marker is
 * always an error, since silently passing the string through would let
 * callers accidentally send the literal `"@@..."` to a service.
 * @returns A tree in which markers have been replaced by their live
 * references and everything else is unchanged.
 */
export function expandMarkers(
  value: unknown,
  resolve: (name: string) => unknown,
): unknown {
  if (typeof value === 'string') {
    const match = MARKER_PATTERN.exec(value);
    if (!match) {
      return value;
    }
    const name = match[1] as string;
    const resolved = resolve(name);
    if (resolved === undefined) {
      throw new BridgeRpcError(
        JSON_RPC_ERROR.INVALID_PARAMS,
        `unknown reference marker "@@${name}"`,
      );
    }
    return resolved;
  }
  if (Array.isArray(value)) {
    return value.map((item) => expandMarkers(item, resolve));
  }
  if (typeof value === 'object' && value !== null) {
    const out: Record<string, unknown> = {};
    for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
      out[key] = expandMarkers(val, resolve);
    }
    return out;
  }
  return value;
}
 
/**
 * Identify a thenable. Checked structurally rather than via `passStyleOf`
 * so this module stays free of environment assumptions — it takes
 * `isRemotable` as a hook for the same reason — and so that a CapTP promise
 * or any other foreign thenable is recognized alongside a native one.
 *
 * @param value - The value to test.
 * @returns True if `value` has a callable `then`.
 */
function isThenable(value: unknown): boolean {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof (value as { then?: unknown }).then === 'function'
  );
}
 
/**
 * Walk `value`, replacing every remotable (as identified by
 * `isRemotable`) with `"${MARKER_PREFIX}${assign(remotable)}"`.
 * Descends into arrays and record-like objects. Primitives pass
 * through unchanged.
 *
 * The result is a JSON-safe tree ready for `JSON.stringify`.
 *
 * @throws If the tree contains a value with no JSON form that
 * `JSON.stringify` would nonetheless accept — an unsettled promise (which
 * becomes `{}`) or a non-finite number (which becomes `null`) — since either
 * would reach the client as a silently wrong success.
 *
 * @param value - The value to walk.
 * @param isRemotable - Predicate identifying a value that should be
 * substituted for a marker.
 * @param assign - Callback that turns a remotable into a marker NAME
 * (assigning one on first sight, reusing on subsequent sight).
 * @returns A JSON-safe tree with remotables replaced by marker strings.
 */
export function substituteRemotables(
  value: unknown,
  isRemotable: (candidate: unknown) => boolean,
  assign: (remotable: unknown) => string,
): unknown {
  if (isRemotable(value)) {
    return `${MARKER_PREFIX}${assign(value)}`;
  }
  if (Array.isArray(value)) {
    return value.map((item) => substituteRemotables(item, isRemotable, assign));
  }
  if (isThenable(value)) {
    // A promise has no own enumerable properties, so the object walk below
    // would quietly turn it into `{}` — and `JSON.stringify` would accept
    // that, handing the client a plausible-looking success payload with the
    // value silently missing. Refusing is the only honest option here:
    // awaiting an arbitrarily nested promise could block the connection for
    // as long as it stays unsettled. A method that returns a promise-valued
    // field has to settle it before returning.
    throw new BridgeRpcError(
      JSON_RPC_ERROR.INTERNAL_ERROR,
      'result contains an unsettled promise, which has no JSON form',
    );
  }
  if (typeof value === 'number' && !Number.isFinite(value)) {
    // JSON has no way to write `NaN` or `±Infinity`, and `JSON.stringify`
    // does not complain — it emits `null`. That is indistinguishable from
    // the `null` a void method legitimately produces (`successResponse`
    // normalizes `undefined` to `null`), so the client cannot tell a missing
    // value from a real one. Same reasoning as the promise case above:
    // silently wrong is worse than an explicit failure.
    //
    // `-0` is deliberately allowed through. It serializes to `0`, which is
    // a numerically equal JSON number rather than a value replaced by an
    // unrelated one.
    throw new BridgeRpcError(
      JSON_RPC_ERROR.INTERNAL_ERROR,
      `result contains ${String(value)}, which has no JSON form`,
    );
  }
  if (typeof value === 'object' && value !== null) {
    const out: Record<string, unknown> = {};
    for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
      out[key] = substituteRemotables(val, isRemotable, assign);
    }
    return out;
  }
  return value;
}