Skip to content

Commit

Permalink
feat(utilities): add sleep and sleepSync (#469)
Browse files Browse the repository at this point in the history
* feat: add `sleep` and `sleepSync`

* feat: support ref and abort controller

* chore: add dom to lib

* fix: error DOMException isn't available in older version

* test: proper error check

* build: add dom lib ref

* build: dont add DOMException in index.d.ts

* fix: add proper reason

* fix: apply suggestions

* test: use closeto

* test: 45 will pass all the tests

* test: change sync test to 50+

* fix: exactly 50ms

* docs: add note for sleepSync

* fix: add 51 too
  • Loading branch information
imranbarbhuiya committed Oct 1, 2022
1 parent 36e21d1 commit 8cd1293
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/utilities/src/index.ts
Expand Up @@ -39,6 +39,7 @@ export * from './lib/range';
export * from './lib/regExpEsc';
export * from './lib/roundNumber';
export * from './lib/splitText';
export * from './lib/sleep';
export { toTitleCase, ToTitleCaseOptions } from './lib/toTitleCase';
export * from './lib/tryParse';
export * from './lib/utilityTypes';
68 changes: 68 additions & 0 deletions packages/utilities/src/lib/sleep.ts
@@ -0,0 +1,68 @@
export interface SleepOptions {
/**
* When provided the corresponding `AbortController` can be used to cancel an asynchronous action.
*/
signal?: AbortSignal | undefined;

/**
* Set to `false` to indicate that the scheduled `Timeout`
* should not require the Node.js event loop to remain active.
* @default true
*/
ref?: boolean | undefined;
}

export class AbortError extends Error {
public readonly code: string;
public constructor(
message?: string,
options?: {
cause?: unknown;
}
) {
super(message, options);
this.name = 'AbortError';
this.code = 'ERR_ABORT';
}
}

/**
* Sleeps for the specified number of milliseconds.
* @param ms The number of milliseconds to sleep.
* @param value A value with which the promise is fulfilled.
* @see {@link sleepSync} for a synchronous version.
*/
export function sleep<T = undefined>(ms: number, value?: T, options?: SleepOptions): Promise<T> {
return new Promise((resolve, reject) => {
const timer: NodeJS.Timeout | number = setTimeout(() => resolve(value!), ms);
const signal = options?.signal;
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(
new AbortError('The operation was aborted', {
cause: signal.reason
})
);
});
}
if (options?.ref === false && typeof timer === 'object') {
timer.unref();
}
});
}

/**
* Sleeps for the specified number of milliseconds synchronously.
* We should probably note that unlike {@link sleep} (which uses CPU tick times),
* sleepSync uses wall clock times, so the precision is near-absolute by comparison.
* That, and that synchronous means that nothing else in the thread will run for the length of the timer.
* @param ms The number of milliseconds to sleep.
* @param value A value to return.
* @see {@link sleep} for an asynchronous version.
*/
export function sleepSync<T = undefined>(ms: number, value?: T): T {
const end = Date.now() + ms;
while (Date.now() < end) continue;
return value!;
}
1 change: 1 addition & 0 deletions packages/utilities/src/tsconfig.json
@@ -1,6 +1,7 @@
{
"extends": "../../ts-config/extra-strict-without-decorators.json",
"compilerOptions": {
"lib": ["dom", "esnext"],
"rootDir": "./",
"outDir": "../dist",
"incremental": false
Expand Down
68 changes: 68 additions & 0 deletions packages/utilities/tests/sleep.test.ts
@@ -0,0 +1,68 @@
import { AbortError, sleep, sleepSync } from '../src';
import { expectError } from './util/macros/comparators';

const DOMException: typeof globalThis.DOMException =
globalThis.DOMException ??
(() => {
// DOMException was only made a global in Node v17.0.0,
// but our CI runs on Node v16.6.0 too
try {
atob('~');
} catch (err) {
return Object.getPrototypeOf(err).constructor;
}
})();

describe('sleep', () => {
test('GIVEN a number of ms THEN resolve the promise after that time', async () => {
const start = Date.now();
await sleep(50);
expect(Date.now() - start).greaterThanOrEqual(45);
});

test('GIVEN a number of ms and a value THEN resolve the promise after that time with the value', async () => {
const start = Date.now();
const value = await sleep(50, 'test');
expect(Date.now() - start).greaterThanOrEqual(45);
expect<string>(value).toBe('test');
});

test('GIVEN a abort signal THEN reject the promise', async () => {
const controller = new AbortController();
const promise = sleep(1000, 'test', { signal: controller.signal });
controller.abort();
await expectError(
() => promise,
new AbortError('The operation was aborted', {
cause: new DOMException('This operation was aborted', 'AbortError')
})
);
});

test('GIVEN a abort signal with reason THEN reject the promise with the reason as cause', async () => {
const controller = new AbortController();
const promise = sleep(1000, 'test', { signal: controller.signal });
controller.abort('test');
await expectError(
() => promise,
new AbortError('The operation was aborted', {
cause: 'test'
})
);
});
});

describe('sleepSync', () => {
test('GIVEN a number of ms THEN return after that time', () => {
const start = Date.now();
sleepSync(50);
expect(Date.now() - start).oneOf([50, 51]);
});

test('GIVEN a number of ms and a value THEN return after that time with the value', () => {
const start = Date.now();
const value = sleepSync(50, 'test');
expect(Date.now() - start).oneOf([50, 51]);
expect<string>(value).toBe('test');
});
});
1 change: 1 addition & 0 deletions packages/utilities/tests/tsconfig.json
@@ -1,6 +1,7 @@
{
"extends": "../../ts-config/extra-strict-without-decorators.json",
"compilerOptions": {
"lib": ["dom", "esnext"],
"noEmit": true,
"incremental": false,
"types": ["vitest/globals"]
Expand Down
23 changes: 23 additions & 0 deletions packages/utilities/tests/util/macros/comparators.ts
@@ -0,0 +1,23 @@
type _Error = Error & { code?: string };

export function IdenticalError(expected: _Error, actual: _Error) {
expect(actual).toBeDefined();
expect(actual).toBeInstanceOf(expected.constructor);
expect(actual.message).toBe(expected.message);
if (expected.code) expect(actual.code).toBe(expected.code);
if (expected.cause) {
if (expected.cause instanceof Error) IdenticalError(expected.cause, actual.cause as _Error);
else expect(actual.cause).toBe(expected.cause);
}
}

export async function expectError<T = any>(cb: () => T, expected: Error & { code?: string }) {
try {
await cb();
} catch (error) {
IdenticalError(expected, error as _Error);
return;
}

throw new Error('Expected to throw, but failed to do so');
}

0 comments on commit 8cd1293

Please sign in to comment.