diff --git a/packages/device-id/src/get-device-id.spec.ts b/packages/device-id/src/get-device-id.spec.ts index d2e5bde2..22e01a11 100644 --- a/packages/device-id/src/get-device-id.spec.ts +++ b/packages/device-id/src/get-device-id.spec.ts @@ -8,8 +8,7 @@ describe('getDeviceId', function () { const deviceId = await getDeviceId({ getMachineId, - isNodeMachineId: false, - }).value; + }); expect(deviceId).to.be.a('string'); expect(deviceId).to.have.lengthOf(64); // SHA-256 hex digest length @@ -22,48 +21,46 @@ describe('getDeviceId', function () { const resultA = await getDeviceId({ getMachineId, - isNodeMachineId: true, - }).value; + }); const resultB = await getDeviceId({ getMachineId: () => Promise.resolve(mockMachineId.toUpperCase()), - isNodeMachineId: true, - }).value; + }); expect(resultA).to.equal(resultB); }); it('returns "unknown" when machine id is not found', async function () { const getMachineId = () => Promise.resolve(undefined); - let capturedError: Error | undefined; + let capturedError: [string, Error] | undefined; const deviceId = await getDeviceId({ getMachineId, - isNodeMachineId: false, - onError: (error) => { - capturedError = error; + onError: (reason, error) => { + capturedError = [reason, error]; }, - }).value; + }); expect(deviceId).to.equal('unknown'); - expect(capturedError?.message).to.equal('Failed to resolve machine ID'); + expect(capturedError?.[0]).to.equal('resolutionError'); + expect(capturedError?.[1].message).to.equal('Failed to resolve machine ID'); }); it('returns "unknown" and calls onError when getMachineId throws', async function () { const error = new Error('Something went wrong'); const getMachineId = () => Promise.reject(error); - let capturedError: Error | undefined; + let capturedError: [string, Error] | undefined; const result = await getDeviceId({ getMachineId, - isNodeMachineId: false, - onError: (err) => { - capturedError = err; + onError: (reason, err) => { + capturedError = [reason, err]; }, - }).value; + }); expect(result).to.equal('unknown'); - expect(capturedError).to.equal(error); + expect(capturedError?.[0]).to.equal('resolutionError'); + expect(capturedError?.[1]).to.equal(error); }); it('produces consistent hash for the same machine id', async function () { @@ -72,86 +69,84 @@ describe('getDeviceId', function () { const resultA = await getDeviceId({ getMachineId, - isNodeMachineId: false, - }).value; + }); const resultB = await getDeviceId({ getMachineId, - isNodeMachineId: false, - }).value; + }); expect(resultA).to.equal(resultB); }); - it('handles timeout when getting machine id', async function () { - let timeoutId: NodeJS.Timeout; - const getMachineId = () => - new Promise((resolve) => { - timeoutId = setTimeout(() => resolve('delayed-id'), 10_000); + const fallbackTestCases: { + fallbackValue?: string; + expectedResult: string; + }[] = [ + { expectedResult: 'unknown' }, + { fallbackValue: 'fallback-id', expectedResult: 'fallback-id' }, + ]; + + describe('when timed out', function () { + for (const testCase of fallbackTestCases) { + it(`resolves with ${testCase.expectedResult} when fallback value is ${testCase.fallbackValue ?? 'undefined'}`, async function () { + let timeoutId: NodeJS.Timeout; + const getMachineId = () => + new Promise((resolve) => { + timeoutId = setTimeout(() => resolve('delayed-id'), 10_000); + }); + + let capturedError: [string, Error] | undefined; + const result = await getDeviceId({ + getMachineId, + onError: (reason, error) => { + capturedError = [reason, error]; + }, + timeout: 5, + fallbackValue: testCase.fallbackValue, + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + clearTimeout(timeoutId!); + expect(result).to.equal(testCase.expectedResult); + expect(capturedError?.[0]).to.equal('timeout'); + expect(capturedError?.[1].message).to.equal( + 'Timeout reached after 5 ms', + ); }); - - let errorCalled = false; - const result = await getDeviceId({ - getMachineId, - isNodeMachineId: false, - onError: () => { - errorCalled = true; - }, - timeout: 1, - }).value; - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - clearTimeout(timeoutId!); - expect(result).to.equal('unknown'); - expect(errorCalled).to.equal(false); - }); - - it('handles external promise resolution', async function () { - let timeoutId: NodeJS.Timeout; - const getMachineId = () => - new Promise((resolve) => { - timeoutId = setTimeout(() => resolve('delayed-id'), 10_000); - }); - - const { resolve, value } = getDeviceId({ - getMachineId, - isNodeMachineId: false, - }); - - resolve('external-id'); - - const result = await value; - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - clearTimeout(timeoutId!); - expect(result).to.be.a('string'); - expect(result).to.equal('external-id'); - expect(result).to.not.equal('unknown'); + } }); - it('handles external promise rejection', async function () { - let timeoutId: NodeJS.Timeout; - const getMachineId = () => - new Promise((resolve) => { - timeoutId = setTimeout(() => resolve('delayed-id'), 10_000); + describe('when aborted', function () { + for (const testCase of fallbackTestCases) { + it(`resolves with ${testCase.expectedResult} when fallback value is ${testCase.fallbackValue ?? 'undefined'}`, async function () { + let timeoutId: NodeJS.Timeout; + const getMachineId = () => + new Promise((resolve) => { + timeoutId = setTimeout(() => resolve('delayed-id'), 10_000); + }); + + let capturedError: [string, Error] | undefined; + const abortController = new AbortController(); + const value = getDeviceId({ + getMachineId, + abortSignal: abortController.signal, + onError: (reason, error) => { + capturedError = [reason, error]; + }, + fallbackValue: testCase.fallbackValue, + }); + + abortController.abort(); + + const result = await value; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + clearTimeout(timeoutId!); + expect(result).to.be.a('string'); + expect(result).to.equal(testCase.expectedResult); + expect(capturedError?.[0]).to.equal('abort'); + expect(capturedError?.[1].message).to.equal('Aborted by abort signal'); }); - - const error = new Error('External rejection'); - - const { reject, value } = getDeviceId({ - getMachineId, - isNodeMachineId: false, - }); - - reject(error); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - clearTimeout(timeoutId!); - try { - await value; - expect.fail('Expected promise to be rejected'); - } catch (e) { - expect(e).to.equal(error); } }); }); diff --git a/packages/device-id/src/get-device-id.ts b/packages/device-id/src/get-device-id.ts index 6c49fa87..dfb768ed 100644 --- a/packages/device-id/src/get-device-id.ts +++ b/packages/device-id/src/get-device-id.ts @@ -2,86 +2,75 @@ import { createHmac } from 'crypto'; export function getDeviceId({ getMachineId, - isNodeMachineId, onError, timeout = 3000, - onTimeout, -}: GetDeviceIdOptions): { - /** A promise that resolves to the hashed device ID or `"unknown"` if an error or timeout occurs. */ - value: Promise; - /** A function which resolves the device ID promise. */ - resolve: (value: string) => void; - /** A function which rejects the device ID promise. */ - reject: (err: Error) => void; -} { - let resolveDeviceId!: (value: string) => void; - let rejectDeviceId!: (err: Error) => void; + abortSignal, + fallbackValue = 'unknown', +}: GetDeviceIdOptions): Promise { let timeoutId: NodeJS.Timeout | undefined; const value = Promise.race([ resolveMachineId({ getMachineId, - isNodeMachineId, onError, }), - new Promise((resolve, reject) => { + new Promise((resolve) => { + abortSignal?.addEventListener('abort', () => { + onError?.('abort', new Error('Aborted by abort signal')); + resolve(fallbackValue); + }); + timeoutId = setTimeout(() => { - if (onTimeout) { - onTimeout(resolve, reject); - } else { - resolve('unknown'); - } + onError?.('timeout', new Error(`Timeout reached after ${timeout} ms`)); + resolve(fallbackValue); }, timeout).unref?.(); - - resolveDeviceId = resolve; - rejectDeviceId = reject; }), ]).finally(() => clearTimeout(timeoutId)); - return { - value, - resolve: resolveDeviceId, - reject: rejectDeviceId, - }; + return value; } export type GetDeviceIdOptions = { /** A function that returns a raw machine ID. */ getMachineId: () => Promise; - /** When using node-machine-id, the ID is made uppercase to be consistent with other libraries. */ - isNodeMachineId: boolean; + /** Runs when an error occurs while getting the machine ID. */ - onError?: (error: Error) => void; + onError?: ( + reason: 'abort' | 'timeout' | 'resolutionError', + error: Error, + ) => void; + /** Timeout in milliseconds. Defaults to 3000ms. Set to `undefined` to disable. */ timeout?: number | undefined; - /** Runs when the timeout is reached. By default, resolves to "unknown". */ - onTimeout?: ( - resolve: (value: string) => void, - reject: (err: Error) => void, - ) => void; + + /** + * An optional abort signal that will cancel the async device ID resolution and will + * cause getDeviceId to resolve immediately with the value of `fallbackValue`. + */ + abortSignal?: AbortSignal; + + /** + * An optional fallback value that will be returned if the abort signal is triggered + * or the timeout is reached. Defaults to "unknown". + */ + fallbackValue?: string; }; async function resolveMachineId({ getMachineId, - isNodeMachineId, onError, }: GetDeviceIdOptions): Promise { try { - const originalId = isNodeMachineId - ? (await getMachineId())?.toUpperCase() - : await getMachineId(); + const originalId = (await getMachineId())?.toUpperCase(); if (!originalId) { - onError?.(new Error('Failed to resolve machine ID')); + onError?.('resolutionError', new Error('Failed to resolve machine ID')); return 'unknown'; } // Create a hashed format from the machine ID // to match it exactly with the denisbrodbeck/machineid library that Atlas CLI uses. - const hmac = createHmac( - 'sha256', - isNodeMachineId ? originalId : originalId, - ); + const hmac = createHmac('sha256', originalId); /** This matches the message used to create the hashes in Atlas CLI */ const DEVICE_ID_HASH_MESSAGE = 'atlascli'; @@ -89,7 +78,7 @@ async function resolveMachineId({ hmac.update(DEVICE_ID_HASH_MESSAGE); return hmac.digest('hex'); } catch (error) { - onError?.(error as Error); + onError?.('resolutionError', error as Error); return 'unknown'; } }