Skip to content

Commit

Permalink
feat: wrap r19-specific errors with R19Error
Browse files Browse the repository at this point in the history
  • Loading branch information
angeloashmore committed Mar 15, 2023
1 parent f1c5dfd commit bb88cad
Show file tree
Hide file tree
Showing 5 changed files with 183 additions and 48 deletions.
25 changes: 25 additions & 0 deletions src/R19Error.ts
@@ -0,0 +1,25 @@
type R19ErrorOptions = {
procedurePath?: string[];
procedureArgs?: Record<string, unknown>;
cause?: unknown;
};

export class R19Error extends Error {
procedurePath?: string[];
procedureArgs?: Record<string, unknown>;

constructor(message: string, options: R19ErrorOptions = {}) {
super();

if (Error.captureStackTrace) {
Error.captureStackTrace(this, R19Error);
}

this.name = "R19Error";
this.message = message;
this.cause = options.cause;

this.procedurePath = options.procedurePath;
this.procedureArgs = options.procedureArgs;
}
}
34 changes: 28 additions & 6 deletions src/client/createRPCClient.ts
Expand Up @@ -4,6 +4,8 @@ import { replaceLeaves } from "../lib/replaceLeaves";
import { isErrorLike } from "../lib/isErrorLike";

import { Procedures, Procedure, ProcedureCallServerResponse } from "../types";
import { R19Error } from "../R19Error";
import { isPlainObject } from "../lib/isPlainObject";

const createArbitrarilyNestedFunction = <T>(
handler: (path: string[], args: unknown[]) => unknown,
Expand Down Expand Up @@ -92,16 +94,31 @@ export const createRPCClient = <TProcedures extends Procedures>(
const resolvedFetch: FetchLike =
args.fetch || globalThis.fetch.bind(globalThis);

return createArbitrarilyNestedFunction(async (path, fnArgs) => {
return createArbitrarilyNestedFunction(async (procedurePath, fnArgs) => {
const procedureArgs = fnArgs[0] as Record<string, unknown>;

if (procedureArgs !== undefined && !isPlainObject(procedureArgs)) {
throw new R19Error(
"r19 only supports a single object procedure argument, but something else was provided.",
{
procedurePath,
procedureArgs,
},
);
}

const preparedProcedureArgs = await replaceLeaves(
fnArgs[0],
procedureArgs,
async (value) => {
if (value instanceof Blob) {
return new Uint8Array(await value.arrayBuffer());
}

if (typeof value === "function") {
throw new Error("r19 does not support function arguments.");
throw new R19Error("r19 does not support function arguments.", {
procedurePath,
procedureArgs,
});
}

return value;
Expand All @@ -110,7 +127,7 @@ export const createRPCClient = <TProcedures extends Procedures>(

const body = encode(
{
procedurePath: path,
procedurePath: procedurePath,
procedureArgs: preparedProcedureArgs,
},
{ ignoreUndefined: true },
Expand All @@ -133,15 +150,20 @@ export const createRPCClient = <TProcedures extends Procedures>(
const resError = resObject.error;

if (isErrorLike(resError)) {
const error = new Error(resError.message);
const error = new R19Error(resError.message, {
procedurePath,
procedureArgs,
});
error.name = resError.name;
error.stack = resError.stack;

throw error;
} else {
throw new Error(
throw new R19Error(
"An unexpected response was received from the RPC server.",
{
procedurePath,
procedureArgs,
cause: resObject,
},
);
Expand Down
48 changes: 28 additions & 20 deletions src/handleRPCRequest.ts
Expand Up @@ -10,6 +10,7 @@ import {
ProcedureCallServerArgs,
OnErrorEventHandler,
} from "./types";
import { R19Error } from "./R19Error";

const findProcedure = (
procedures: Procedures,
Expand Down Expand Up @@ -66,17 +67,20 @@ export const handleRPCRequest = async <TProcedures extends Procedures>(
};

if (!procedure) {
const rawBody = {
name: "RPCError",
message: `Invalid procedure name: ${clientArgs.procedurePath.join(".")}`,
};
const body = encode(rawBody);

args.onError?.({
error: new Error(`${rawBody.name}: ${rawBody.message}`),
...clientArgs,
const error = new R19Error("Invalid procedure name", {
procedurePath: clientArgs.procedurePath,
procedureArgs: clientArgs.procedureArgs,
});

const body = encode(
{
error,
},
{ ignoreUndefined: true },
);

args.onError?.({ error, ...clientArgs });

return {
body,
headers,
Expand All @@ -102,8 +106,6 @@ export const handleRPCRequest = async <TProcedures extends Procedures>(

res = await replaceLeaves(res, async (value) => {
if (isErrorLike(value)) {
args.onError?.({ error: value, ...clientArgs });

return {
name: value.name,
message: value.message,
Expand All @@ -113,7 +115,10 @@ export const handleRPCRequest = async <TProcedures extends Procedures>(
}

if (typeof value === "function") {
throw new Error("r19 does not support function return values.");
throw new R19Error("r19 does not support function return values.", {
procedurePath: clientArgs.procedurePath,
procedureArgs: clientArgs.procedureArgs,
});
}

return value;
Expand Down Expand Up @@ -158,15 +163,18 @@ export const handleRPCRequest = async <TProcedures extends Procedures>(
};
} catch (error) {
if (error instanceof Error) {
console.error(error);

const body = encode({
error: {
name: "RPCError",
message:
"Unable to serialize server response. Check the server log for details.",
const rpcError = new R19Error(
"Unable to serialize server response. Check the server log for details.",
{
procedurePath: clientArgs.procedurePath,
procedureArgs: clientArgs.procedureArgs,
cause: error,
},
});
);

console.error(rpcError);

const body = encode(rpcError);

args.onError?.({ error, ...clientArgs });

Expand Down
17 changes: 17 additions & 0 deletions src/lib/isPlainObject.ts
@@ -0,0 +1,17 @@
export const isPlainObject = (
value: unknown,
): value is Record<PropertyKey, unknown> => {
if (typeof value !== "object" || value === null) {
return false;
}

const prototype = Object.getPrototypeOf(value);

return (
(prototype === null ||
prototype === Object.prototype ||
Object.getPrototypeOf(prototype) === null) &&
!(Symbol.toStringTag in value) &&
!(Symbol.iterator in value)
);
};
107 changes: 85 additions & 22 deletions test/index.test.ts
Expand Up @@ -186,42 +186,59 @@ it("supports namespaced procedures", async () => {

it("does not support function arguments", async () => {
const procedures = { ping: (args: { fn: () => void }) => args.fn() };
const server = startRPCTestServer({ procedures });
const onError = vi.fn();
const server = startRPCTestServer({ procedures, onError });

const client = createRPCClient<typeof procedures>({
serverURL: server.url,
fetch,
});

const fnArg = () => void 0;
await expect(async () => {
await client.ping({
fn: () => void 0,
});
}).rejects.toThrow(/does not support function arguments/i);
await client.ping({ fn: fnArg });
}).rejects.toThrow(
expect.objectContaining({
name: "R19Error",
message: expect.stringMatching(/does not support function arguments/i),
procedurePath: ["ping"],
procedureArgs: { fn: fnArg },
}),
);

server.close();

expect(onError).not.toHaveBeenCalled();
});

it("does not support function return values", async () => {
const procedures = { ping: () => () => void 0 };
const server = startRPCTestServer({ procedures });
const onError = vi.fn();
const server = startRPCTestServer({ procedures, onError });

const client = createRPCClient<typeof procedures>({
serverURL: server.url,
fetch,
});

const consoleErrorSpy = vi
.spyOn(globalThis.console, "error")
.mockImplementation(() => void 0);
const expectedError = expect.objectContaining({
name: "R19Error",
message: expect.stringMatching(/does not support function return values/i),
procedurePath: ["ping"],
procedureArgs: undefined,
});

await expect(async () => {
await client.ping();
}).rejects.toThrow(/does not support function return values/i);

consoleErrorSpy.mockRestore();
}).rejects.toThrow(expectedError);

server.close();

expect(onError).toHaveBeenCalledWith({
error: expectedError,
procedurePath: ["ping"],
procedureArgs: undefined,
});
});

it("does not support class arguments", async () => {
Expand All @@ -232,20 +249,31 @@ it("does not support class arguments", async () => {
}

const procedures = { ping: (args: { foo: Foo }) => args.foo.bar() };
const server = startRPCTestServer({ procedures });
const onError = vi.fn();
const server = startRPCTestServer({ procedures, onError });

const client = createRPCClient<typeof procedures>({
serverURL: server.url,
fetch,
});

const expectedError = expect.objectContaining({
name: "TypeError",
message: expect.stringMatching(/args.foo.bar is not a function/i),
});

const fooArg = new Foo();
await expect(async () => {
await client.ping({
foo: new Foo(),
});
}).rejects.toThrow(/args.foo.bar is not a function/i);
await client.ping({ foo: fooArg });
}).rejects.toThrow(expectedError);

server.close();

expect(onError).toHaveBeenCalledWith({
error: expectedError,
procedurePath: ["ping"],
procedureArgs: { foo: fooArg },
});
});

it("does not support class return values with methods", async () => {
Expand All @@ -256,7 +284,8 @@ it("does not support class return values with methods", async () => {
}

const procedures = { ping: () => new Foo() };
const server = startRPCTestServer({ procedures });
const onError = vi.fn();
const server = startRPCTestServer({ procedures, onError });

const client = createRPCClient<typeof procedures>({
serverURL: server.url,
Expand All @@ -268,6 +297,8 @@ it("does not support class return values with methods", async () => {
server.close();

expect(res).toStrictEqual({});

expect(onError).not.toHaveBeenCalled();
});

it("supports `onError` event handler", async () => {
Expand All @@ -284,14 +315,15 @@ it("supports `onError` event handler", async () => {
fetch,
});

await expect(client.throw({ input: "foo" })).rejects.toMatchInlineSnapshot(
"[Error: foo]",
);
await expect(client.throw({ input: "foo" })).rejects.toThrow("foo");

server.close();

expect(onError).toHaveBeenLastCalledWith({
error: expect.any(Error),
error: expect.objectContaining({
name: "Error",
message: "foo",
}),
procedureArgs: {
input: "foo",
},
Expand All @@ -316,3 +348,34 @@ it("returns 405 if POST method is not used", async () => {

expect(res.status).toBe(405);
});

it("throws if a non-existent procedure is called", async () => {
const procedures = { ping: () => void 0 };
const onError = vi.fn();
const server = startRPCTestServer({ procedures, onError });

const client = createRPCClient<typeof procedures>({
serverURL: server.url,
fetch,
});

const expectedError = expect.objectContaining({
name: "R19Error",
message: expect.stringMatching(/invalid procedure name/i),
procedurePath: ["pong"],
procedureArgs: { input: "foo" },
});

await expect(async () => {
// @ts-expect-error - We are purposely calling a non-existent procedure.
await client.pong({ input: "foo" });
}).rejects.toThrow(expectedError);

server.close();

expect(onError).toHaveBeenCalledWith({
error: expectedError,
procedurePath: ["pong"],
procedureArgs: { input: "foo" },
});
});

0 comments on commit bb88cad

Please sign in to comment.