Skip to content

Commit

Permalink
feat: Add error serialization hooks to TypedMessenger (#486)
Browse files Browse the repository at this point in the history
  • Loading branch information
jespertheend committed Mar 29, 2023
1 parent e4aebcf commit 3b38b62
Show file tree
Hide file tree
Showing 2 changed files with 161 additions and 2 deletions.
57 changes: 55 additions & 2 deletions src/util/TypedMessenger.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,51 @@ export class TypedMessenger {
* But when this is true you should return an object with the format
* `{returnValue: any, transfer?: Transferable[]}`.
* Note that transferring objects that are passed in as arguments is always
* supported. You can use {@linkcode _sendInternal} for this. This option
* supported. You can use {@linkcode sendWithTransfer} for this. This option
* is only useful if you wish to transfer objects from return values.
* For more info see
* https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects.
* @param {((error: unknown) => unknown)?} [options.serializeErrorHook] This hook allows you to
* serialize thrown errors before transferring them to the other TypedMessenger.
* If you are using a worker or iframe, regular 'Error' objects are automatically serialized.
* But if you have extended the 'Error' object, or you are sending json to websockets, then you'll have to
* serialize your errors manually.
*
* ## Example
* This hook is called whenever an error is about to get sent to the other end, giving you a chance to transform it.
* For instance you could check if the error is an instance of your custom error like so:
*
* ```js
* const messenger = new TypedMessenger({
* serializeErrorHook(error) {
* if (error instanceof MyError) {
* return {
* type: "MyError",
* message: error.message,
* }
* }
* return error;
* },
* });
* ```
*
* Then on the receiving end:
* ```js
* const messenger = new TypedMessenger({
* deserializeErrorHook(error) {
* if (error.type == "MyError") {
* return new MyError(error.message);
* }
* return error;
* },
* });
* ```
* @param {((error: unknown) => unknown)?} [options.deserializeErrorHook] See {@linkcode serializeErrorHook}.
*/
constructor({
returnTransferSupport = /** @type {TRequireHandlerReturnObjects} */ (false),
serializeErrorHook = null,
deserializeErrorHook = null,
} = {}) {
/** @private */
this.returnTransferSupport = returnTransferSupport;
Expand All @@ -202,6 +240,11 @@ export class TypedMessenger {
/** @private @type {Map<number, Set<(message: TypedMessengerResponseMessageSendData<TReq, keyof TReq, TRequireHandlerReturnObjects>) => void>>} */
this.onRequestIdMessageCbs = new Map();

/** @private */
this.serializeErrorHook = serializeErrorHook;
/** @private */
this.deserializeErrorHook = deserializeErrorHook;

const proxy = new Proxy({}, {
get: (target, prop, receiver) => {
if (typeof prop == "symbol") {
Expand Down Expand Up @@ -244,6 +287,8 @@ export class TypedMessenger {
});
/**
* This is the same as {@linkcode send}, but the first argument is an array
* that contains the objects that should be transferred.
* For more info see https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
*/
this.sendWithTransfer = /** @type {TypedMessengerWithTransferProxy<TReq, TRequireHandlerReturnObjects>} */ (sendWithTransferProxy);
}
Expand Down Expand Up @@ -340,6 +385,9 @@ export class TypedMessenger {
returnValue = await handler(...data.args);
} catch (e) {
returnValue = e;
if (this.serializeErrorHook) {
returnValue = this.serializeErrorHook(returnValue);
}
didThrow = true;
}
}
Expand Down Expand Up @@ -432,7 +480,12 @@ export class TypedMessenger {
const promise = new Promise((resolve, reject) => {
this.onResponseMessage(requestId, message => {
if (message.didThrow) {
reject(message.returnValue);
/** @type {unknown} */
let rejectValue = message.returnValue;
if (this.deserializeErrorHook) {
rejectValue = this.deserializeErrorHook(rejectValue);
}
reject(rejectValue);
} else {
resolve(message.returnValue);
}
Expand Down
106 changes: 106 additions & 0 deletions test/unit/src/util/TypedMessenger/TypedMessenger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,109 @@ Deno.test({
});
},
});

Deno.test({
name: "Serializing and deserializing errors",
async fn() {
class MyError extends Error {
/**
* @param {string} message
*/
constructor(message) {
super(message);
this.name = "MyError";
}
}

class UnhandledError extends Error {
/**
* @param {string} message
*/
constructor(message) {
super(message);
this.name = "UnhandledError";
}
}

const handlers = {
throwMyError() {
throw new MyError("Error message");
},
throwError() {
throw new Error("Error message");
},
throwUnhandledError() {
throw new UnhandledError("Error message");
},
};

/** @type {TypedMessenger<{}, typeof handlers>} */
const messengerA = new TypedMessenger({
serializeErrorHook(error) {
if (error instanceof MyError) {
return {
type: "myError",
message: error.message,
};
} else if (error instanceof UnhandledError) {
// In this test we're explicitly returning 'undefined',
// but this meant to test a case where the user forgets to handle and return an error.
return undefined;
}
return error;
},
});

/**
* @typedef SerializedError
* @property {string} type
* @property {string} message
*/

/** @type {TypedMessenger<typeof handlers, {}>} */
const messengerB = new TypedMessenger({
deserializeErrorHook: error => {
if (error) {
const castError = /** @type {SerializedError} */ (error);
if (castError.type == "myError") {
return new MyError(castError.message);
}
}
return error;
},
});

/**
* @param {any} data
*/
function serialize(data) {
return JSON.parse(JSON.stringify(data));
}

messengerA.setSendHandler(data => {
messengerB.handleReceivedMessage(serialize(data.sendData));
});
messengerB.setSendHandler(data => {
messengerA.handleReceivedMessage(serialize(data.sendData));
});
messengerA.setResponseHandlers(handlers);

// When dealing with workers, `Error` objects are serialized for us.
// However, in this test we're serializing using JSON.parse. In that case errors are flattened to `{}`.
const rejectValue1 = await assertRejects(async () => {
await messengerB.send.throwError();
});
assertEquals(rejectValue1, {});

// The hooks contain serialization logic for MyError.
await assertRejects(async () => {
await messengerB.send.throwMyError();
}, MyError, "Error message");

// But no logic for UnhandledError, so it should be undefined.
const rejectValue2 = await assertRejects(async () => {
await messengerB.send.throwUnhandledError();
});
assertEquals(rejectValue2, undefined);
},
});

0 comments on commit 3b38b62

Please sign in to comment.