feat(rpc): add target policy, protocol schemas, and tagged errors

- Add normalizeLoopbackTarget() with loopback-only URL validation
- Add JSON-RPC response decoder with schema validation
- Define 8 tagged errors using Data.TaggedError
- 20 tests covering target policy and protocol decoding
- Re-export all public types from index.ts
This commit is contained in:
lda
2026-07-02 11:16:29 +07:00 Verified
parent 7583d41b17
commit e149a39b9e
9 changed files with 372 additions and 2 deletions
+7 -2
View File
@@ -13,6 +13,11 @@
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": { "effect": "3.21.4" },
"devDependencies": { "vitest": "4.1.9" }
"dependencies": {
"effect": "3.21.4"
},
"devDependencies": {
"@types/node": "26.1.0",
"vitest": "4.1.9"
}
}
+44
View File
@@ -0,0 +1,44 @@
import { Data } from "effect";
export class InvalidTargetError extends Data.TaggedError("InvalidTargetError")<{
readonly message: string;
}> {}
export class UnknownOperationError extends Data.TaggedError(
"UnknownOperationError",
)<{
readonly message: string;
}> {}
export class UpstreamConnectionError extends Data.TaggedError(
"UpstreamConnectionError",
)<{
readonly message: string;
}> {}
export class UpstreamTimeoutError extends Data.TaggedError(
"UpstreamTimeoutError",
)<{
readonly message: string;
}> {}
export class UpstreamResponseTooLargeError extends Data.TaggedError(
"UpstreamResponseTooLargeError",
)<{
readonly message: string;
}> {}
export class RpcProtocolError extends Data.TaggedError("RpcProtocolError")<{
readonly message: string;
readonly evidence?: string;
}> {}
export class RpcRemoteError extends Data.TaggedError("RpcRemoteError")<{
readonly message: string;
readonly code: number;
readonly data?: string;
}> {}
export class RpcDecodeError extends Data.TaggedError("RpcDecodeError")<{
readonly message: string;
}> {}
+21
View File
@@ -0,0 +1,21 @@
export {
InvalidTargetError,
UnknownOperationError,
UpstreamConnectionError,
UpstreamTimeoutError,
UpstreamResponseTooLargeError,
RpcProtocolError,
RpcRemoteError,
RpcDecodeError,
} from "./errors.js";
export { normalizeLoopbackTarget } from "./target-policy.js";
export { decodeRpcResponse } from "./protocol.js";
export type {
JsonRpcRequest,
JsonRpcResponse,
JsonRpcSuccess,
JsonRpcFailure,
} from "./protocol.js";
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { RpcDecodeError, RpcProtocolError, decodeRpcResponse } from "./protocol.js";
describe("decodeRpcResponse", () => {
it("decodes success envelope with matching string id", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
result: { status: "ok" },
};
expect(decodeRpcResponse(payload, "req-1")).toEqual({
jsonrpc: "2.0",
id: "req-1",
result: { status: "ok" },
});
});
it("decodes error envelope with matching string id", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
error: { code: -32000, message: "server error" },
};
const response = decodeRpcResponse(payload, "req-1");
expect(response).toEqual({
jsonrpc: "2.0",
id: "req-1",
error: { code: -32000, message: "server error" },
});
});
it("rejects mismatched id", () => {
const payload = {
jsonrpc: "2.0",
id: "other",
result: { status: "ok" },
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(
RpcProtocolError,
);
});
it("rejects envelope with both result and error", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
result: { status: "ok" },
error: { code: -32000, message: "err" },
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
});
it("rejects envelope with neither result nor error", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
});
it("rejects wrong jsonrpc version", () => {
const payload = {
jsonrpc: "1.0",
id: "req-1",
result: {},
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
});
it("rejects malformed error object", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
error: { message: "missing code" },
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
});
it("rejects non-object payload", () => {
expect(() => decodeRpcResponse("not an object", "req-1")).toThrow(
RpcDecodeError,
);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { Schema } from "effect";
import { RpcDecodeError, RpcProtocolError } from "./errors.js";
export { RpcDecodeError, RpcProtocolError };
const JsonRpcVersion = Schema.Literal("2.0");
const JsonRpcErrorObject = Schema.Struct({
code: Schema.Number,
message: Schema.String,
data: Schema.optional(Schema.String),
});
const decodeJsonRpcVersion = Schema.decodeUnknownSync(JsonRpcVersion);
const decodeErrorObject = Schema.decodeUnknownSync(JsonRpcErrorObject);
export type JsonRpcRequest = {
readonly jsonrpc: "2.0";
readonly id: string;
readonly method: string;
readonly params: unknown;
};
export type JsonRpcSuccess = {
readonly jsonrpc: "2.0";
readonly id: string;
readonly result: unknown;
};
export type JsonRpcFailure = {
readonly jsonrpc: "2.0";
readonly id: string;
readonly error: {
readonly code: number;
readonly message: string;
readonly data?: string;
};
};
export type JsonRpcResponse = JsonRpcSuccess | JsonRpcFailure;
function throwRpcDecode(message: string): never {
throw new RpcDecodeError({ message });
}
export function decodeRpcResponse(
value: unknown,
expectedId: string,
): JsonRpcResponse {
if (typeof value !== "object" || value === null) {
throwRpcDecode("response must be a JSON object");
}
const obj = value as Record<string, unknown>;
if (!("jsonrpc" in obj) || !("id" in obj)) {
throwRpcDecode("response must contain 'jsonrpc' and 'id' fields");
}
try {
decodeJsonRpcVersion(obj.jsonrpc, { onExcessProperty: "error" });
} catch {
throwRpcDecode(`jsonrpc version must be "2.0", got ${JSON.stringify(obj.jsonrpc)}`);
}
if (typeof obj.id !== "string") {
throwRpcDecode(`response id must be a string, got ${typeof obj.id}`);
}
if (obj.id !== expectedId) {
throw new RpcProtocolError({
message: `response id "${obj.id}" does not match expected id "${expectedId}"`,
evidence: JSON.stringify(obj),
});
}
const hasResult = "result" in obj;
const hasError = "error" in obj;
if (hasResult && hasError) {
throwRpcDecode("response must not contain both 'result' and 'error'");
}
if (!hasResult && !hasError) {
throwRpcDecode("response must contain exactly one of 'result' or 'error'");
}
if (hasResult) {
return { jsonrpc: "2.0", id: obj.id, result: obj.result };
}
if (typeof obj.error !== "object" || obj.error === null) {
throwRpcDecode("'error' must be an object");
}
let errorObj: { readonly code: number; readonly message: string; readonly data?: string | undefined };
try {
errorObj = decodeErrorObject(obj.error, { onExcessProperty: "error" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throwRpcDecode(`invalid error object: ${msg}`);
}
const error: { code: number; message: string; data?: string } = {
code: errorObj.code,
message: errorObj.message,
};
if (errorObj.data !== undefined) {
error.data = errorObj.data;
}
return { jsonrpc: "2.0", id: obj.id, error };
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { InvalidTargetError, normalizeLoopbackTarget } from "./target-policy.js";
describe("normalizeLoopbackTarget", () => {
it.each([
["http://127.0.0.1:8765/rpc", "http://127.0.0.1:8765/rpc"],
["http://localhost:8765/rpc", "http://localhost:8765/rpc"],
["http://[::1]:8765/rpc", "http://[::1]:8765/rpc"],
])("accepts loopback target %s", (input, expected) => {
expect(normalizeLoopbackTarget(input)).toBe(expected);
});
it.each([
"https://127.0.0.1:8765/rpc",
"http://example.com:8765/rpc",
"http://user:[email protected]:8765/rpc",
"http://127.0.0.1/rpc",
"http://127.0.0.1:8765/rpc?x=1",
"http://127.0.0.1:8765/rpc#fragment",
])("rejects unsafe target %s", (input) => {
expect(() => normalizeLoopbackTarget(input)).toThrow(InvalidTargetError);
});
it("rejects invalid URL", () => {
expect(() => normalizeLoopbackTarget("not a url")).toThrow(
InvalidTargetError,
);
});
it("rejects port 0", () => {
expect(() => normalizeLoopbackTarget("http://127.0.0.1:0/rpc")).toThrow(
InvalidTargetError,
);
});
it("rejects port above 65535", () => {
expect(
() => normalizeLoopbackTarget("http://127.0.0.1:65536/rpc"),
).toThrow(InvalidTargetError);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { InvalidTargetError } from "./errors.js";
export { InvalidTargetError };
const ALLOWED_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]"]);
/**
* Normalize and validate a loopback RPC target URL.
*
* Only `http://127.0.0.1`, `http://localhost`, and `http://[::1]` are accepted.
* DNS names other than the literal `localhost` are rejected to avoid
* DNS-rebinding ambiguity — a hostname that resolves to a non-loopback
* address at resolution time would bypass this check.
*/
export function normalizeLoopbackTarget(raw: string): string {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new InvalidTargetError({ message: "invalid URL" });
}
if (url.protocol !== "http:") {
throw new InvalidTargetError({ message: "only http: protocol is allowed" });
}
if (!ALLOWED_HOSTNAMES.has(url.hostname)) {
throw new InvalidTargetError({
message: `hostname "${url.hostname}" is not an allowed loopback address`,
});
}
if (url.username !== "" || url.password !== "") {
throw new InvalidTargetError({ message: "credentials in URL are not allowed" });
}
if (url.port === "") {
throw new InvalidTargetError({ message: "explicit port is required" });
}
const port = Number(url.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new InvalidTargetError({
message: `port ${url.port} is outside valid range 1..65535`,
});
}
if (url.search !== "") {
throw new InvalidTargetError({ message: "query string is not allowed" });
}
if (url.hash !== "") {
throw new InvalidTargetError({ message: "fragment is not allowed" });
}
return url.toString();
}
+2
View File
@@ -5,6 +5,8 @@
"moduleResolution": "NodeNext",
"composite": true,
"declaration": true,
"lib": ["ES2023"],
"types": ["node"],
"rootDir": "src",
"outDir": "dist"
},
+3
View File
@@ -92,6 +92,9 @@ importers:
specifier: 3.21.4
version: 3.21.4
devDependencies:
'@types/node':
specifier: 26.1.0
version: 26.1.0
vitest:
specifier: 4.1.9
version: 4.1.9(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected]))