mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
Compare commits
2 Commits
7dce7ed482
...
feat/stdli
| Author | SHA1 | Date | |
|---|---|---|---|
| a61fb85088 | |||
| 01b13d6a65 |
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -16,14 +16,14 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
4
.github/workflows/publish.yaml
vendored
4
.github/workflows/publish.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Check version changes and publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/tsconfig"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"files": [
|
||||
"**tsconfig.json"
|
||||
|
||||
16
core/platform/build.config.ts
Normal file
16
core/platform/build.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: [
|
||||
'src/browsers',
|
||||
'src/multi',
|
||||
],
|
||||
clean: true,
|
||||
declaration: true,
|
||||
rollup: {
|
||||
emitCJS: true,
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -18,9 +18,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/platform"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -29,22 +29,22 @@
|
||||
"exports": {
|
||||
"./browsers": {
|
||||
"types": "./dist/browsers.d.ts",
|
||||
"import": "./dist/browsers.js",
|
||||
"import": "./dist/browsers.mjs",
|
||||
"require": "./dist/browsers.cjs"
|
||||
},
|
||||
"./multi": {
|
||||
"types": "./dist/multi.d.ts",
|
||||
"import": "./dist/multi.js",
|
||||
"import": "./dist/multi.mjs",
|
||||
"require": "./dist/multi.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"tsdown": "catalog:"
|
||||
"unbuild": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
browsers: 'src/browsers/index.ts',
|
||||
multi: 'src/multi/index.ts',
|
||||
},
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
hash: false,
|
||||
});
|
||||
9
core/stdlib/build.config.ts
Normal file
9
core/stdlib/build.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
rollup: {
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -18,9 +18,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/stdlib"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -29,17 +29,18 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"tsdown": "catalog:"
|
||||
"pathe": "catalog:",
|
||||
"unbuild": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './retry';
|
||||
export * from './sleep';
|
||||
export * from './tryIt';
|
||||
|
||||
250
core/stdlib/src/async/retry/index.test.ts
Normal file
250
core/stdlib/src/async/retry/index.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { retry } from '.';
|
||||
|
||||
describe('retry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('return the result on first successful attempt', async () => {
|
||||
const successFn = vi.fn().mockResolvedValue('success');
|
||||
|
||||
const result = await retry(successFn);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(successFn).toHaveBeenCalledTimes(1);
|
||||
expect(successFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
|
||||
});
|
||||
|
||||
it('use default times value of 2', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(retry(failingFn)).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retry the specified number of times on failure', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(retry(failingFn, { times: 3 })).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
expect(failingFn).toHaveBeenNthCalledWith(1, { count: 1, stop: expect.any(Function) });
|
||||
expect(failingFn).toHaveBeenNthCalledWith(2, { count: 2, stop: expect.any(Function) });
|
||||
expect(failingFn).toHaveBeenNthCalledWith(3, { count: 3, stop: expect.any(Function) });
|
||||
});
|
||||
|
||||
it('succeed on the last attempt', async () => {
|
||||
const partiallyFailingFn = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('First failure'))
|
||||
.mockRejectedValueOnce(new Error('Second failure'))
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const result = await retry(partiallyFailingFn, { times: 3 });
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(partiallyFailingFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('use custom shouldRetry function', async () => {
|
||||
const networkError = new Error('Network failed');
|
||||
networkError.name = 'NetworkError';
|
||||
const failingFn = vi.fn().mockRejectedValue(networkError);
|
||||
|
||||
await expect(retry(failingFn, {
|
||||
times: 3,
|
||||
shouldRetry: (error) => error.name !== 'NetworkError'
|
||||
})).rejects.toThrow('Network failed');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retry with custom shouldRetry based on count', async () => {
|
||||
const testError = new Error('Test error');
|
||||
const failingFn = vi.fn().mockRejectedValue(testError);
|
||||
|
||||
await expect(retry(failingFn, {
|
||||
times: 5,
|
||||
shouldRetry: (error, count) => count < 3 // Only retry first 2 attempts
|
||||
})).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
||||
});
|
||||
|
||||
it('retry specific error types with custom shouldRetry', async () => {
|
||||
const temporaryError = new Error('Temporary failure');
|
||||
temporaryError.name = 'TemporaryError';
|
||||
const permanentError = new Error('Permanent failure');
|
||||
permanentError.name = 'PermanentError';
|
||||
|
||||
const failingFn = vi.fn()
|
||||
.mockRejectedValueOnce(temporaryError)
|
||||
.mockRejectedValueOnce(temporaryError)
|
||||
.mockRejectedValueOnce(permanentError);
|
||||
|
||||
await expect(retry(failingFn, {
|
||||
times: 5,
|
||||
shouldRetry: (error) => error.name === 'TemporaryError'
|
||||
})).rejects.toThrow('Permanent failure');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('wait for the specified delay between retries', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
const retryPromise = retry(failingFn, { times: 3, delay: 1000 });
|
||||
|
||||
// First call should happen immediately
|
||||
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time to trigger first retry
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Advance time to trigger second retry
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await expect(retryPromise).rejects.toThrow('Test error');
|
||||
});
|
||||
|
||||
it('use dynamic delay function', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
const delayFn = vi.fn((count: number) => count * 500);
|
||||
|
||||
const retryPromise = retry(failingFn, { times: 3, delay: delayFn });
|
||||
|
||||
// First call should happen immediately
|
||||
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// First retry should wait for delay(2) = 1000ms
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||
expect(delayFn).toHaveBeenCalledWith(2);
|
||||
|
||||
// Second retry should wait for delay(3) = 1500ms
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
expect(delayFn).toHaveBeenCalledWith(3);
|
||||
|
||||
await expect(retryPromise).rejects.toThrow('Test error');
|
||||
});
|
||||
|
||||
it('not delay after the last attempt', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
const retryPromise = retry(failingFn, { times: 2, delay: 1000 });
|
||||
|
||||
// Wait for the first retry delay
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Should complete without further delays
|
||||
await expect(retryPromise).rejects.toThrow('Test error');
|
||||
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handle zero delay', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(retry(failingFn, { times: 3, delay: 0 })).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('pass the count parameter to the function', async () => {
|
||||
const countingFn = vi.fn(async ({ count }: { count: number }) => {
|
||||
if (count < 3) {
|
||||
throw new Error(`Attempt ${count} failed`);
|
||||
}
|
||||
return `Success on attempt ${count}`;
|
||||
});
|
||||
|
||||
const result = await retry(countingFn, { times: 3 });
|
||||
|
||||
expect(result).toBe('Success on attempt 3');
|
||||
expect(countingFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
|
||||
expect(countingFn).toHaveBeenCalledWith({ count: 2, stop: expect.any(Function) });
|
||||
expect(countingFn).toHaveBeenCalledWith({ count: 3, stop: expect.any(Function) });
|
||||
});
|
||||
|
||||
it('throw the last error when all attempts fail', async () => {
|
||||
const firstError = new Error('First error');
|
||||
const lastError = new Error('Last error');
|
||||
const failingFn = vi.fn()
|
||||
.mockRejectedValueOnce(firstError)
|
||||
.mockRejectedValueOnce(lastError);
|
||||
|
||||
await expect(retry(failingFn, { times: 2 })).rejects.toThrow('Last error');
|
||||
});
|
||||
|
||||
it('handle times value of 1', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(retry(failingFn, { times: 1 })).rejects.toThrow('Test error');
|
||||
|
||||
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handle function that returns non-promise values', async () => {
|
||||
const syncFn = vi.fn(async ({ count }: { count: number }) => {
|
||||
if (count === 1) {
|
||||
throw new Error('First attempt failed');
|
||||
}
|
||||
return 'success';
|
||||
});
|
||||
|
||||
const result = await retry(syncFn, { times: 2 });
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(syncFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handle complex return types', async () => {
|
||||
const complexFn = vi.fn().mockResolvedValue({
|
||||
data: [1, 2, 3],
|
||||
status: 'ok',
|
||||
metadata: { timestamp: 123456 }
|
||||
});
|
||||
|
||||
const result = await retry(complexFn);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [1, 2, 3],
|
||||
status: 'ok',
|
||||
metadata: { timestamp: 123456 }
|
||||
});
|
||||
});
|
||||
|
||||
it('stop retrying when stop function is called', async () => {
|
||||
const customError = new Error('Custom stop error');
|
||||
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error: any) => void }) => {
|
||||
if (count === 2) {
|
||||
stop(customError);
|
||||
}
|
||||
throw new Error(`Attempt ${count} failed`);
|
||||
});
|
||||
|
||||
await expect(retry(stopFn, { times: 5 })).rejects.toThrow('Custom stop error');
|
||||
|
||||
expect(stopFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stop retrying with undefined error when stop is called without argument', async () => {
|
||||
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error?: any) => void }) => {
|
||||
if (count === 2) {
|
||||
stop();
|
||||
}
|
||||
throw new Error(`Attempt ${count} failed`);
|
||||
});
|
||||
|
||||
await expect(retry(stopFn, { times: 5 })).rejects.toBeUndefined();
|
||||
|
||||
expect(stopFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,22 @@
|
||||
import { tryIt } from '../tryIt';
|
||||
import { sleep } from '../sleep';
|
||||
import { isFunction } from '../../types';
|
||||
|
||||
export interface RetryOptions {
|
||||
times?: number;
|
||||
delay?: number;
|
||||
backoff: (options: RetryOptions & { count: number }) => number;
|
||||
delay?: number | ((count: number) => number);
|
||||
shouldRetry?: (error: Error, count: number) => boolean;
|
||||
}
|
||||
|
||||
export type RetryFunction<Return> = (
|
||||
args: {
|
||||
count: number;
|
||||
stop: (error: any) => void;
|
||||
},
|
||||
) => Promise<Return>;
|
||||
|
||||
const RetryEarlyExit = Symbol('RetryEarlyExit');
|
||||
|
||||
/**
|
||||
* @name retry
|
||||
* @category Async
|
||||
@@ -25,14 +38,51 @@ export interface RetryOptions {
|
||||
* .then(response => response.json())
|
||||
* }, { times: 3, delay: 1000 });
|
||||
*
|
||||
* @since 0.0.8
|
||||
*/
|
||||
export async function retry<Return>(
|
||||
fn: () => Promise<Return>,
|
||||
options: RetryOptions
|
||||
) {
|
||||
fn: RetryFunction<Return>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<Return> {
|
||||
const {
|
||||
times = 3,
|
||||
times = 2,
|
||||
delay = 0,
|
||||
shouldRetry,
|
||||
} = options;
|
||||
|
||||
let count = 0;
|
||||
let count = 1;
|
||||
let lastError: Error = new Error('Retry failed');
|
||||
|
||||
while (count <= times) {
|
||||
const metadata = {
|
||||
count,
|
||||
stop: (error?: any) => {
|
||||
throw { [RetryEarlyExit]: error };
|
||||
},
|
||||
};
|
||||
|
||||
const { error, data } = await tryIt(fn)(metadata);
|
||||
|
||||
if (!error)
|
||||
return data;
|
||||
|
||||
if (RetryEarlyExit in error)
|
||||
throw error[RetryEarlyExit];
|
||||
|
||||
if (shouldRetry && !shouldRetry(error, count))
|
||||
throw error;
|
||||
|
||||
lastError = error;
|
||||
count++;
|
||||
|
||||
// Don't delay after the last attempt
|
||||
if (count <= times) {
|
||||
const delayMs = isFunction(delay) ? delay(count) : delay;
|
||||
|
||||
if (delayMs > 0)
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
@@ -6,62 +6,62 @@ describe('tryIt', () => {
|
||||
const syncFn = (x: number) => x * 2;
|
||||
const wrappedSyncFn = tryIt(syncFn);
|
||||
|
||||
const [error, result] = wrappedSyncFn(2);
|
||||
const { error, data } = wrappedSyncFn(2);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(result).toBe(4);
|
||||
expect(data).toBe(4);
|
||||
});
|
||||
|
||||
it('handle synchronous functions with errors', () => {
|
||||
const syncFn = (): void => { throw new Error('Test error') };
|
||||
const wrappedSyncFn = tryIt(syncFn);
|
||||
|
||||
const [error, result] = wrappedSyncFn();
|
||||
const { error, data } = wrappedSyncFn();
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error?.message).toBe('Test error');
|
||||
expect(result).toBeUndefined();
|
||||
expect(data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handle asynchronous functions without errors', async () => {
|
||||
const asyncFn = async (x: number) => x * 2;
|
||||
const wrappedAsyncFn = tryIt(asyncFn);
|
||||
|
||||
const [error, result] = await wrappedAsyncFn(2);
|
||||
const { error, data } = await wrappedAsyncFn(2);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(result).toBe(4);
|
||||
expect(data).toBe(4);
|
||||
});
|
||||
|
||||
it('handle asynchronous functions with errors', async () => {
|
||||
const asyncFn = async () => { throw new Error('Test error') };
|
||||
const wrappedAsyncFn = tryIt(asyncFn);
|
||||
|
||||
const [error, result] = await wrappedAsyncFn();
|
||||
const { error, data } = await wrappedAsyncFn();
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error?.message).toBe('Test error');
|
||||
expect(result).toBeUndefined();
|
||||
expect(data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handle promise-based functions without errors', async () => {
|
||||
const promiseFn = (x: number) => Promise.resolve(x * 2);
|
||||
const wrappedPromiseFn = tryIt(promiseFn);
|
||||
|
||||
const [error, result] = await wrappedPromiseFn(2);
|
||||
const { error, data } = await wrappedPromiseFn(2);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(result).toBe(4);
|
||||
expect(data).toBe(4);
|
||||
});
|
||||
|
||||
it('handle promise-based functions with errors', async () => {
|
||||
const promiseFn = () => Promise.reject(new Error('Test error'));
|
||||
const wrappedPromiseFn = tryIt(promiseFn);
|
||||
|
||||
const [error, result] = await wrappedPromiseFn();
|
||||
const { error, data } = await wrappedPromiseFn();
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error?.message).toBe('Test error');
|
||||
expect(result).toBeUndefined();
|
||||
expect(data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isPromise } from '../../types';
|
||||
|
||||
export type TryItReturn<Return> = Return extends Promise<any>
|
||||
? Promise<[Error, undefined] | [undefined, Awaited<Return>]>
|
||||
: [Error, undefined] | [undefined, Return];
|
||||
? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited<Return> }>
|
||||
: { error: Error; data: undefined } | { error: undefined; data: Return };
|
||||
|
||||
/**
|
||||
* @name tryIt
|
||||
@@ -14,10 +14,10 @@ export type TryItReturn<Return> = Return extends Promise<any>
|
||||
*
|
||||
* @example
|
||||
* const wrappedFetch = tryIt(fetch);
|
||||
* const [error, result] = await wrappedFetch('https://jsonplaceholder.typicode.com/todos/1');
|
||||
* const { error, data } = await wrappedFetch('https://jsonplaceholder.typicode.com/todos/1');
|
||||
*
|
||||
* @example
|
||||
* const [error, result] = await tryIt(fetch)('https://jsonplaceholder.typicode.com/todos/1');
|
||||
* const { error, data } = await tryIt(fetch)('https://jsonplaceholder.typicode.com/todos/1');
|
||||
*
|
||||
* @since 0.0.3
|
||||
*/
|
||||
@@ -30,12 +30,12 @@ export function tryIt<Args extends any[], Return>(
|
||||
|
||||
if (isPromise(result))
|
||||
return result
|
||||
.then((value) => [undefined, value])
|
||||
.catch((error) => [error, undefined]) as TryItReturn<Return>;
|
||||
.then((value) => ({ error: undefined, data: value }))
|
||||
.catch((error) => ({ error, data: undefined })) as TryItReturn<Return>;
|
||||
|
||||
return [undefined, result] as TryItReturn<Return>;
|
||||
return { error: undefined, data: result } as TryItReturn<Return>;
|
||||
} catch (error) {
|
||||
return [error, undefined] as TryItReturn<Return>;
|
||||
return { error, data: undefined } as TryItReturn<Return>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
hash: false,
|
||||
});
|
||||
@@ -16,9 +16,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/renovate"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"files": [
|
||||
"default.json"
|
||||
@@ -27,6 +27,6 @@
|
||||
"test": "renovate-config-validator ./default.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"renovate": "^43.12.0"
|
||||
"renovate": "^41.43.5"
|
||||
}
|
||||
}
|
||||
|
||||
18
package.json
18
package.json
@@ -15,20 +15,20 @@
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.11",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
"citty": "^0.2.1",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "catalog:",
|
||||
"@types/node": "^22.16.5",
|
||||
"citty": "^0.1.6",
|
||||
"jiti": "^2.5.1",
|
||||
"scule": "^1.3.0",
|
||||
"vitest": "catalog:"
|
||||
"jsdom": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
|
||||
7049
pnpm-lock.yaml
generated
7049
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,13 @@ packages:
|
||||
- core/*
|
||||
- infra/*
|
||||
- web/*
|
||||
- docs
|
||||
|
||||
catalog:
|
||||
'@vitest/coverage-v8': ^4.0.18
|
||||
'@vitest/coverage-v8': ^3.2.4
|
||||
'@vue/test-utils': ^2.4.6
|
||||
jsdom: ^28.0.0
|
||||
tsdown: ^0.12.5
|
||||
vitest: ^4.0.18
|
||||
'@vitest/ui': ^4.0.18
|
||||
vue: ^3.5.28
|
||||
nuxt: ^4.3.1
|
||||
jsdom: ^26.1.0
|
||||
pathe: ^2.0.3
|
||||
unbuild: 3.6.0
|
||||
vitest: ^3.2.4
|
||||
'@vitest/ui': ^3.2.4
|
||||
vue: ^3.5.18
|
||||
|
||||
11
web/vue/build.config.ts
Normal file
11
web/vue/build.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
externals: ['vue'],
|
||||
rollup: {
|
||||
inlineDependencies: true,
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/vue",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.9",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Collection of powerful tools for Vue",
|
||||
"keywords": [
|
||||
@@ -16,9 +16,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "./packages/vue"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
"node": ">=22.17.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -27,23 +27,23 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@robonen/platform": "workspace:*",
|
||||
"@robonen/stdlib": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"unbuild": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from './tryOnBeforeMount';
|
||||
export * from './tryOnMounted';
|
||||
export * from './tryOnScopeDispose';
|
||||
export * from './unrefElement';
|
||||
export * from './useAppSharedState';
|
||||
export * from './useAsyncState';
|
||||
export * from './useCached';
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computed, defineComponent, nextTick, ref, shallowRef } from 'vue';
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { unrefElement } from '.';
|
||||
|
||||
describe('unrefElement', () => {
|
||||
it('returns a plain element when passed a raw element', () => {
|
||||
const htmlEl = document.createElement('div');
|
||||
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
|
||||
expect(unrefElement(htmlEl)).toBe(htmlEl);
|
||||
expect(unrefElement(svgEl)).toBe(svgEl);
|
||||
});
|
||||
|
||||
it('returns element when passed a ref or shallowRef to an element', () => {
|
||||
const el = document.createElement('div');
|
||||
const elRef = ref<HTMLElement | null>(el);
|
||||
const shallowElRef = shallowRef<HTMLElement | null>(el);
|
||||
|
||||
expect(unrefElement(elRef)).toBe(el);
|
||||
expect(unrefElement(shallowElRef)).toBe(el);
|
||||
});
|
||||
|
||||
it('returns element when passed a computed ref or getter function', () => {
|
||||
const el = document.createElement('div');
|
||||
const computedElRef = computed(() => el);
|
||||
const elGetter = () => el;
|
||||
|
||||
expect(unrefElement(computedElRef)).toBe(el);
|
||||
expect(unrefElement(elGetter)).toBe(el);
|
||||
});
|
||||
|
||||
it('returns component $el when passed a component instance', async () => {
|
||||
const Child = defineComponent({
|
||||
template: `<span class="child-el">child</span>`,
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
template: `<Child ref="childRef" />`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
await nextTick();
|
||||
|
||||
const childInstance = (wrapper.vm as any).$refs.childRef;
|
||||
const result = unrefElement(childInstance);
|
||||
|
||||
expect(result).toBe(childInstance.$el);
|
||||
expect((result as HTMLElement).classList.contains('child-el')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles null and undefined values', () => {
|
||||
expect(unrefElement(undefined)).toBe(undefined);
|
||||
expect(unrefElement(null)).toBe(null);
|
||||
expect(unrefElement(ref(null))).toBe(null);
|
||||
expect(unrefElement(ref(undefined))).toBe(undefined);
|
||||
expect(unrefElement(() => null)).toBe(null);
|
||||
expect(unrefElement(() => undefined)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter } from 'vue';
|
||||
import { toValue } from 'vue';
|
||||
|
||||
export type VueInstance = ComponentPublicInstance;
|
||||
export type MaybeElement = HTMLElement | SVGElement | VueInstance | undefined | null;
|
||||
|
||||
export type MaybeElementRef<El extends MaybeElement = MaybeElement> = MaybeRef<El>;
|
||||
export type MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;
|
||||
|
||||
export type UnRefElementReturn<T extends MaybeElement = MaybeElement> = T extends VueInstance ? Exclude<MaybeElement, VueInstance> : T | undefined;
|
||||
|
||||
/**
|
||||
* @name unrefElement
|
||||
* @category Components
|
||||
* @description Unwraps a Vue element reference to get the underlying instance or DOM element.
|
||||
*
|
||||
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.
|
||||
* @returns {UnRefElementReturn<El>} - The unwrapped element or undefined.
|
||||
*
|
||||
* @example
|
||||
* const element = useTemplateRef<HTMLElement>('element');
|
||||
* const result = unrefElement(element); // result is the element instance
|
||||
*
|
||||
* @example
|
||||
* const component = useTemplateRef<Component>('component');
|
||||
* const result = unrefElement(component); // result is the component instance
|
||||
*
|
||||
* @since 0.0.11
|
||||
*/
|
||||
export function unrefElement<El extends MaybeElement>(elRef: MaybeComputedElementRef<El>): UnRefElementReturn<El> {
|
||||
const plain = toValue(elRef);
|
||||
return (plain as VueInstance)?.$el ?? plain;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { inject as vueInject, provide as vueProvide, type InjectionKey, type App } from 'vue';
|
||||
import { inject, provide, type InjectionKey, type App } from 'vue';
|
||||
import { VueToolsError } from '../..';
|
||||
|
||||
/**
|
||||
@@ -34,8 +34,8 @@ import { VueToolsError } from '../..';
|
||||
export function useContextFactory<ContextValue>(name: string) {
|
||||
const injectionKey: InjectionKey<ContextValue> = Symbol(name);
|
||||
|
||||
const inject = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
|
||||
const context = vueInject(injectionKey, fallback);
|
||||
const injectContext = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
|
||||
const context = inject(injectionKey, fallback);
|
||||
|
||||
if (context !== undefined)
|
||||
return context;
|
||||
@@ -43,8 +43,8 @@ export function useContextFactory<ContextValue>(name: string) {
|
||||
throw new VueToolsError(`useContextFactory: '${name}' context is not provided`);
|
||||
};
|
||||
|
||||
const provide = (context: ContextValue) => {
|
||||
vueProvide(injectionKey, context);
|
||||
const provideContext = (context: ContextValue) => {
|
||||
provide(injectionKey, context);
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -54,8 +54,8 @@ export function useContextFactory<ContextValue>(name: string) {
|
||||
};
|
||||
|
||||
return {
|
||||
inject,
|
||||
provide,
|
||||
inject: injectContext,
|
||||
provide: provideContext,
|
||||
appProvide,
|
||||
key: injectionKey,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useContextFactory } from '../useContextFactory';
|
||||
import type { App, InjectionKey } from 'vue';
|
||||
import { inject, provide, type App, type InjectionKey } from 'vue';
|
||||
|
||||
export interface useInjectionStoreOptions<Return> {
|
||||
injectionName?: string;
|
||||
injectionKey: string | InjectionKey<Return>;
|
||||
defaultValue?: Return;
|
||||
}
|
||||
|
||||
@@ -47,23 +46,23 @@ export interface useInjectionStoreOptions<Return> {
|
||||
*/
|
||||
export function useInjectionStore<Args extends any[], Return>(
|
||||
stateFactory: (...args: Args) => Return,
|
||||
options?: useInjectionStoreOptions<Return>
|
||||
options?: useInjectionStoreOptions<Return>,
|
||||
) {
|
||||
const ctx = useContextFactory<Return>(options?.injectionName ?? stateFactory.name ?? 'InjectionStore');
|
||||
const key = options?.injectionKey ?? Symbol(stateFactory.name ?? 'InjectionStore');
|
||||
|
||||
const useProvidingState = (...args: Args) => {
|
||||
const state = stateFactory(...args);
|
||||
ctx.provide(state);
|
||||
provide(key, state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const useAppProvidingState = (app: App) => (...args: Args) => {
|
||||
const state = stateFactory(...args);
|
||||
ctx.appProvide(app)(state);
|
||||
app.provide(key, state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const useInjectedState = () => ctx.inject(options?.defaultValue);
|
||||
const useInjectedState = () => inject(key, options?.defaultValue);
|
||||
|
||||
return {
|
||||
useProvidingState,
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('useOffsetPagination', () => {
|
||||
await nextTick();
|
||||
|
||||
expect(onPageChange).toHaveBeenCalledTimes(1);
|
||||
expect(onPageChange.mock.calls[0]![0]).toHaveProperty('currentPage', currentPage.value);
|
||||
expect(onPageChange).toHaveBeenCalledWith(expect.objectContaining({ currentPage: currentPage.value }));
|
||||
});
|
||||
|
||||
it('call onPageSizeChange callback', async () => {
|
||||
@@ -81,7 +81,7 @@ describe('useOffsetPagination', () => {
|
||||
await nextTick();
|
||||
|
||||
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||
expect(onPageSizeChange.mock.calls[0]![0]).toHaveProperty('currentPageSize', currentPageSize.value);
|
||||
expect(onPageSizeChange).toHaveBeenCalledWith(expect.objectContaining({ currentPageSize: currentPageSize.value }));
|
||||
});
|
||||
|
||||
it('call onPageCountChange callback', async () => {
|
||||
@@ -93,7 +93,7 @@ describe('useOffsetPagination', () => {
|
||||
await nextTick();
|
||||
|
||||
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||
expect(onTotalPagesChange.mock.calls[0]![0]).toHaveProperty('totalPages', totalPages.value);
|
||||
expect(onTotalPagesChange).toHaveBeenCalledWith(expect.objectContaining({ totalPages: totalPages.value }));
|
||||
});
|
||||
|
||||
it('handle complex reactive options', async () => {
|
||||
|
||||
@@ -1,39 +1,51 @@
|
||||
import { isRef, ref, toValue, type MaybeRefOrGetter, type MaybeRef, type Ref } from 'vue';
|
||||
|
||||
export interface UseToggleOptions<Truthy, Falsy> {
|
||||
truthyValue?: MaybeRefOrGetter<Truthy>,
|
||||
falsyValue?: MaybeRefOrGetter<Falsy>,
|
||||
// TODO: wip
|
||||
|
||||
export interface UseToggleOptions<Enabled, Disabled> {
|
||||
enabledValue?: MaybeRefOrGetter<Enabled>,
|
||||
disabledValue?: MaybeRefOrGetter<Disabled>,
|
||||
}
|
||||
|
||||
export function useToggle<Truthy = true, Falsy = false>(
|
||||
initialValue?: MaybeRef<Truthy | Falsy>,
|
||||
options?: UseToggleOptions<Truthy, Falsy>,
|
||||
): { value: Ref<Truthy | Falsy>, toggle: (value?: Truthy | Falsy) => Truthy | Falsy };
|
||||
// two overloads
|
||||
// 1. const [state, toggle] = useToggle(nonRefValue, options)
|
||||
// 2. const toggle = useToggle(refValue, options)
|
||||
// 3. const [state, toggle] = useToggle() // true, false by default
|
||||
|
||||
export function useToggle<Truthy = true, Falsy = false>(
|
||||
initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy,
|
||||
options: UseToggleOptions<Truthy, Falsy> = {},
|
||||
export function useToggle<V extends Enabled | Disabled, Enabled = true, Disabled = false>(
|
||||
initialValue: Ref<V>,
|
||||
options?: UseToggleOptions<Enabled, Disabled>,
|
||||
): (value?: V) => V;
|
||||
|
||||
export function useToggle<V extends Enabled | Disabled, Enabled = true, Disabled = false>(
|
||||
initialValue?: V,
|
||||
options?: UseToggleOptions<Enabled, Disabled>,
|
||||
): [Ref<V>, (value?: V) => V];
|
||||
|
||||
export function useToggle<V extends Enabled | Disabled, Enabled = true, Disabled = false>(
|
||||
initialValue: MaybeRef<V> = false,
|
||||
options: UseToggleOptions<Enabled, Disabled> = {},
|
||||
) {
|
||||
const {
|
||||
truthyValue = true as Truthy,
|
||||
falsyValue = false as Falsy,
|
||||
enabledValue = false,
|
||||
disabledValue = true,
|
||||
} = options;
|
||||
|
||||
const value = ref(initialValue) as Ref<Truthy | Falsy>;
|
||||
const state = ref(initialValue) as Ref<V>;
|
||||
|
||||
const toggle = (newValue?: Truthy | Falsy) => {
|
||||
if (newValue !== undefined) {
|
||||
value.value = newValue;
|
||||
return value.value;
|
||||
const toggle = (value?: V) => {
|
||||
if (arguments.length) {
|
||||
state.value = value!;
|
||||
return state.value;
|
||||
}
|
||||
|
||||
const truthy = toValue(truthyValue);
|
||||
const falsy = toValue(falsyValue);
|
||||
const enabled = toValue(enabledValue);
|
||||
const disabled = toValue(disabledValue);
|
||||
|
||||
value.value = value.value === truthy ? falsy : truthy;
|
||||
state.value = state.value === enabled ? disabled : enabled;
|
||||
|
||||
return value.value;
|
||||
return state.value;
|
||||
};
|
||||
|
||||
return { value, toggle };
|
||||
return isRef(initialValue) ? toggle : [state, toggle];
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
hash: false,
|
||||
external: ['vue'],
|
||||
noExternal: [/^@robonen\//],
|
||||
});
|
||||
Reference in New Issue
Block a user