From d00e2bcd0d73898e69ee2319a1ab34443454f6c2 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 14 May 2020 14:29:15 -0500 Subject: [PATCH] std/node: add util.callbackify This adds [Node's `util.callbackify`][0] to `std/node/util.ts`. I lifted most of this from the [original Node source code][1] (and [its tests][2]). I tried to make minimal modifications to the source. I made a few arbitrary decisions: - I was unable to do the function's types elegantly. I made overloads for functions that have 0 to 5 (inclusive) arguments, excluding the callback. I would love to know a better way to do this. (It seems that the folks at DefinitelyTyped [were also stumped][3], though maybe their solution is deliberate.) - There are a few edge cases that cause custom Node errors to be produced. Instead of re-implementing those errors completely, I created simplified classes. These are mostly correct but are not identical to the real Node errors. - The tests implement a possibly-arcane `TestQueue` class. I originally used a lot of inline promises but found it too repetitive. Closes [#5366][4]. [0]: https://nodejs.org/api/util.html#util_util_callbackify_original [1]: https://github.com/nodejs/node/blob/47804933012841f2dc90626bdcc161adf34569a5/lib/util.js#L183-L226 [2]: https://github.com/nodejs/node/blob/47804933012841f2dc90626bdcc161adf34569a5/test/parallel/test-util-callbackify.js [3]: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/7d24857ddb07ab637dfa8c477d13352f8a8206fc/types/node/util.d.ts#L61-L84 [4]: https://github.com/denoland/deno/issues/5366 --- std/node/_util/_util_callbackify.ts | 125 ++++++++ std/node/_util/_util_callbackify_test.ts | 364 +++++++++++++++++++++++ std/node/util.ts | 2 + 3 files changed, 491 insertions(+) create mode 100644 std/node/_util/_util_callbackify.ts create mode 100644 std/node/_util/_util_callbackify_test.ts diff --git a/std/node/_util/_util_callbackify.ts b/std/node/_util/_util_callbackify.ts new file mode 100644 index 00000000000000..fdcaed526823a1 --- /dev/null +++ b/std/node/_util/_util_callbackify.ts @@ -0,0 +1,125 @@ +// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +// +// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// These are simplified versions of the "real" errors in Node. +class NodeFalsyValueRejectionError extends Error { + public reason: unknown; + public code = "ERR_FALSY_VALUE_REJECTION"; + constructor(reason: unknown) { + super("Promise was rejected with falsy value"); + this.reason = reason; + } +} +class NodeInvalidArgTypeError extends TypeError { + public code = "ERR_INVALID_ARG_TYPE"; + constructor(argumentName: string) { + super(`The ${argumentName} argument must be of type function.`); + } +} + +type Callback = + | ((err: Error) => void) + | ((err: null, result: ResultT) => void); + +function callbackify( + fn: () => PromiseLike +): (callback: Callback) => void; +function callbackify( + fn: (arg: ArgT) => PromiseLike +): (arg: ArgT, callback: Callback) => void; +function callbackify( + fn: (arg1: Arg1T, arg2: Arg2T) => PromiseLike +): (arg1: Arg1T, arg2: Arg2T, callback: Callback) => void; +function callbackify( + fn: (arg1: Arg1T, arg2: Arg2T, arg3: Arg3T) => PromiseLike +): (arg1: Arg1T, arg2: Arg2T, arg3: Arg3T, callback: Callback) => void; +function callbackify( + fn: ( + arg1: Arg1T, + arg2: Arg2T, + arg3: Arg3T, + arg4: Arg4T + ) => PromiseLike +): ( + arg1: Arg1T, + arg2: Arg2T, + arg3: Arg3T, + arg4: Arg4T, + callback: Callback +) => void; +function callbackify( + fn: ( + arg1: Arg1T, + arg2: Arg2T, + arg3: Arg3T, + arg4: Arg4T, + arg5: Arg5T + ) => PromiseLike +): ( + arg1: Arg1T, + arg2: Arg2T, + arg3: Arg3T, + arg4: Arg4T, + arg5: Arg5T, + callback: Callback +) => void; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function callbackify(original: any): any { + if (typeof original !== "function") { + throw new NodeInvalidArgTypeError('"original"'); + } + + const callbackified = function (this: unknown, ...args: unknown[]): void { + const maybeCb = args.pop(); + if (typeof maybeCb !== "function") { + throw new NodeInvalidArgTypeError("last"); + } + const cb = (...args: unknown[]): void => { + maybeCb.apply(this, args); + }; + original.apply(this, args).then( + (ret: unknown) => { + setTimeout(cb.bind(this, null, ret), 0); + }, + (rej: unknown) => { + rej = rej || new NodeFalsyValueRejectionError(rej); + queueMicrotask(cb.bind(this, rej)); + } + ); + }; + + const descriptors = Object.getOwnPropertyDescriptors(original); + // It is possible to manipulate a functions `length` or `name` property. This + // guards against the manipulation. + if (typeof descriptors.length.value === "number") { + descriptors.length.value++; + } + if (typeof descriptors.name.value === "string") { + descriptors.name.value += "Callbackified"; + } + Object.defineProperties(callbackified, descriptors); + return callbackified; +} + +export { callbackify }; diff --git a/std/node/_util/_util_callbackify_test.ts b/std/node/_util/_util_callbackify_test.ts new file mode 100644 index 00000000000000..b4dcae75507ad3 --- /dev/null +++ b/std/node/_util/_util_callbackify_test.ts @@ -0,0 +1,364 @@ +// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +// +// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const { test } = Deno; +import { assert, assertStrictEq } from "../../testing/asserts.ts"; +import { callbackify } from "./_util_callbackify.ts"; + +const values = [ + "hello world", + null, + undefined, + false, + 0, + {}, + { key: "value" }, + Symbol("I am a symbol"), + function ok(): void {}, + ["array", "with", 4, "values"], + new Error("boo"), +]; + +class TestQueue { + #waitingPromise: Promise; + #resolve?: () => void; + #reject?: (err: unknown) => void; + #queueSize = 0; + + constructor() { + this.#waitingPromise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + }); + } + + enqueue(fn: (done: () => void) => void): void { + this.#queueSize++; + try { + fn(() => { + this.#queueSize--; + if (this.#queueSize === 0) { + assert( + this.#resolve, + "Test setup error; async queue is missing #resolve" + ); + this.#resolve(); + } + }); + } catch (err) { + assert(this.#reject, "Test setup error; async queue is missing #reject"); + this.#reject(err); + } + } + + waitForCompletion(): Promise { + return this.#waitingPromise; + } +} + +test("callbackify passes the resolution value as the second argument to the callback", async () => { + const testQueue = new TestQueue(); + + for (const value of values) { + // eslint-disable-next-line require-await + async function asyncFn(): Promise { + return value; + } + const cbAsyncFn = callbackify(asyncFn); + testQueue.enqueue((done) => { + cbAsyncFn((err: unknown, ret: unknown) => { + assertStrictEq(err, null); + assertStrictEq(ret, value); + done(); + }); + }); + + function promiseFn(): Promise { + return Promise.resolve(value); + } + const cbPromiseFn = callbackify(promiseFn); + testQueue.enqueue((done) => { + cbPromiseFn((err: unknown, ret: unknown) => { + assertStrictEq(err, null); + assertStrictEq(ret, value); + done(); + }); + }); + + function thenableFn(): PromiseLike { + return { + then(onfulfilled): PromiseLike { + assert(onfulfilled); + onfulfilled(value); + return this; + }, + }; + } + const cbThenableFn = callbackify(thenableFn); + testQueue.enqueue((done) => { + cbThenableFn((err: unknown, ret: unknown) => { + assertStrictEq(err, null); + assertStrictEq(ret, value); + done(); + }); + }); + } + + await testQueue.waitForCompletion(); +}); + +test("callbackify passes the rejection value as the first argument to the callback", async () => { + const testQueue = new TestQueue(); + + for (const value of values) { + // eslint-disable-next-line require-await + async function asyncFn(): Promise { + return Promise.reject(value); + } + const cbAsyncFn = callbackify(asyncFn); + assertStrictEq(cbAsyncFn.length, 1); + assertStrictEq(cbAsyncFn.name, "asyncFnCallbackified"); + testQueue.enqueue((done) => { + cbAsyncFn((err: unknown, ret: unknown) => { + assertStrictEq(ret, undefined); + if (err instanceof Error) { + if ("reason" in err) { + assert(!value); + assertStrictEq((err as any).code, "ERR_FALSY_VALUE_REJECTION"); + assertStrictEq((err as any).reason, value); + } else { + assertStrictEq(String(value).endsWith(err.message), true); + } + } else { + assertStrictEq(err, value); + } + done(); + }); + }); + + function promiseFn(): Promise { + return Promise.reject(value); + } + const obj = {}; + Object.defineProperty(promiseFn, "name", { + value: obj, + writable: false, + enumerable: false, + configurable: true, + }); + + const cbPromiseFn = callbackify(promiseFn); + assertStrictEq(promiseFn.name, obj); + testQueue.enqueue((done) => { + cbPromiseFn((err: unknown, ret: unknown) => { + assertStrictEq(ret, undefined); + if (err instanceof Error) { + if ("reason" in err) { + assert(!value); + assertStrictEq((err as any).code, "ERR_FALSY_VALUE_REJECTION"); + assertStrictEq((err as any).reason, value); + } else { + assertStrictEq(String(value).endsWith(err.message), true); + } + } else { + assertStrictEq(err, value); + } + done(); + }); + }); + + function thenableFn(): PromiseLike { + return { + then(onfulfilled, onrejected): PromiseLike { + assert(onrejected); + onrejected(value); + return this; + }, + }; + } + + const cbThenableFn = callbackify(thenableFn); + testQueue.enqueue((done) => { + cbThenableFn((err: unknown, ret: unknown) => { + assertStrictEq(ret, undefined); + if (err instanceof Error) { + if ("reason" in err) { + assert(!value); + assertStrictEq((err as any).code, "ERR_FALSY_VALUE_REJECTION"); + assertStrictEq((err as any).reason, value); + } else { + assertStrictEq(String(value).endsWith(err.message), true); + } + } else { + assertStrictEq(err, value); + } + done(); + }); + }); + } + + await testQueue.waitForCompletion(); +}); + +test("callbackify passes arguments to the original", async () => { + const testQueue = new TestQueue(); + + for (const value of values) { + // eslint-disable-next-line require-await + async function asyncFn(arg: T): Promise { + assertStrictEq(arg, value); + return arg; + } + + const cbAsyncFn = callbackify(asyncFn); + assertStrictEq(cbAsyncFn.length, 2); + assert(Object.getPrototypeOf(cbAsyncFn) !== Object.getPrototypeOf(asyncFn)); + assertStrictEq(Object.getPrototypeOf(cbAsyncFn), Function.prototype); + testQueue.enqueue((done) => { + cbAsyncFn(value, (err: unknown, ret: unknown) => { + assertStrictEq(err, null); + assertStrictEq(ret, value); + done(); + }); + }); + + function promiseFn(arg: T): Promise { + assertStrictEq(arg, value); + return Promise.resolve(arg); + } + const obj = {}; + Object.defineProperty(promiseFn, "length", { + value: obj, + writable: false, + enumerable: false, + configurable: true, + }); + + const cbPromiseFn = callbackify(promiseFn); + assertStrictEq(promiseFn.length, obj); + testQueue.enqueue((done) => { + cbPromiseFn(value, (err: unknown, ret: unknown) => { + assertStrictEq(err, null); + assertStrictEq(ret, value); + done(); + }); + }); + } + + await testQueue.waitForCompletion(); +}); + +test("callbackify preserves the `this` binding", async () => { + const testQueue = new TestQueue(); + + for (const value of values) { + const objectWithSyncFunction = { + fn(this: unknown, arg: typeof value): Promise { + assertStrictEq(this, objectWithSyncFunction); + return Promise.resolve(arg); + }, + }; + const cbSyncFunction = callbackify(objectWithSyncFunction.fn); + testQueue.enqueue((done) => { + cbSyncFunction.call(objectWithSyncFunction, value, function ( + this: unknown, + err: unknown, + ret: unknown + ) { + assertStrictEq(err, null); + assertStrictEq(ret, value); + assertStrictEq(this, objectWithSyncFunction); + done(); + }); + }); + + const objectWithAsyncFunction = { + // eslint-disable-next-line require-await + async fn(this: unknown, arg: typeof value): Promise { + assertStrictEq(this, objectWithAsyncFunction); + return arg; + }, + }; + const cbAsyncFunction = callbackify(objectWithAsyncFunction.fn); + testQueue.enqueue((done) => { + cbAsyncFunction.call(objectWithAsyncFunction, value, function ( + this: unknown, + err: unknown, + ret: unknown + ) { + assertStrictEq(err, null); + assertStrictEq(ret, value); + assertStrictEq(this, objectWithAsyncFunction); + done(); + }); + }); + } + + await testQueue.waitForCompletion(); +}); + +test("callbackify throws with non-function inputs", () => { + ["foo", null, undefined, false, 0, {}, Symbol(), []].forEach((value) => { + try { + callbackify(value as any); + throw Error("We should never reach this error"); + } catch (err) { + assert(err instanceof TypeError); + assertStrictEq((err as any).code, "ERR_INVALID_ARG_TYPE"); + assertStrictEq(err.name, "TypeError"); + assertStrictEq( + err.message, + 'The "original" argument must be of type function.' + ); + } + }); +}); + +test("callbackify returns a function that throws if the last argument is not a function", () => { + // eslint-disable-next-line require-await + async function asyncFn(): Promise { + return 42; + } + + const cb = callbackify(asyncFn) as any; + const args: unknown[] = []; + + ["foo", null, undefined, false, 0, {}, Symbol(), []].forEach((value) => { + args.push(value); + + try { + cb(...args); + throw Error("We should never reach this error"); + } catch (err) { + assert(err instanceof TypeError); + assertStrictEq((err as any).code, "ERR_INVALID_ARG_TYPE"); + assertStrictEq(err.name, "TypeError"); + assertStrictEq( + err.message, + "The last argument must be of type function." + ); + } + }); +}); diff --git a/std/node/util.ts b/std/node/util.ts index 73cbbf8238b7e8..a61dc30b2a5fdf 100644 --- a/std/node/util.ts +++ b/std/node/util.ts @@ -1,3 +1,5 @@ +export { callbackify } from "./_util/_util_callbackify.ts"; + export function isArray(value: unknown): boolean { return Array.isArray(value); }