Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add error serialization hooks to TypedMessenger #486

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);
},
});