chore: initial commit

This commit is contained in:
Lilith 2026-01-21 12:01:07 -08:00
commit 23cfdab64b
40 changed files with 1987 additions and 0 deletions

View file

@ -0,0 +1,179 @@
# =============================================================================
# Forgejo Actions Workflow - TypeScript/npm Package Publishing
# =============================================================================
# Standardized template for TypeScript packages published to Forgejo npm registry
#
# Features:
# - Configuration-driven (reads package.json `_` metadata)
# - Workspace dependency transformation (workspace:* → *)
# - Version existence check (prevents redundant publishes)
# - Graceful error handling (missing scripts don't break CI)
# - Self-signed cert support (internal Forgejo registry)
#
# Usage:
# 1. Copy to: <package>/.forgejo/workflows/publish.yml
# 2. Ensure package.json has metadata:
# "_": { "registry": "forgejo", "publish": true, "build": true }
# 3. Commit and push to main/master
#
# Secrets required:
# - NPM_TOKEN: Forgejo npm registry token
# =============================================================================
name: Build and Publish
on:
push:
branches: [main, master]
workflow_dispatch:
env:
NODE_VERSION: '22'
PNPM_VERSION: '9'
jobs:
build-and-publish:
runs-on: ubuntu-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
run: |
npm install -g pnpm@${{ env.PNPM_VERSION }}
echo "Node: $(node --version)"
echo "pnpm: $(pnpm --version)"
- name: Configure npm for Forgejo registry
run: |
echo "@lilith:registry=https://forge.nasty.sh/api/packages/lilith/npm/" > .npmrc
echo "//forge.nasty.sh/api/packages/lilith/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc
echo "strict-ssl=false" >> .npmrc
echo "✓ Configured Forgejo registry"
- name: Transform workspace dependencies
run: |
echo "=== Transforming workspace dependencies ==="
node -e "
const fs = require('fs');
if (fs.existsSync('package.json')) {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const transform = (deps) => {
if (!deps) return deps;
for (const [name, version] of Object.entries(deps)) {
if (version.startsWith('workspace:') || version.startsWith('file:')) {
console.log(' Transformed:', name, version, '→ *');
deps[name] = '*';
}
}
return deps;
};
pkg.dependencies = transform(pkg.dependencies);
pkg.devDependencies = transform(pkg.devDependencies);
pkg.peerDependencies = transform(pkg.peerDependencies);
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
}
"
echo "✓ Workspace dependencies transformed"
- name: Install dependencies
run: |
echo "=== Installing dependencies ==="
pnpm install --no-frozen-lockfile
echo "✓ Dependencies installed"
- name: Validate
run: |
echo "=== Running validation ==="
# Run typecheck if available
if grep -q '"typecheck"' package.json 2>/dev/null; then
echo "Running typecheck..."
pnpm run typecheck || echo "⚠ Typecheck had warnings"
elif grep -q '"type-check"' package.json 2>/dev/null; then
echo "Running type-check..."
pnpm run type-check || echo "⚠ Type-check had warnings"
else
echo "No typecheck script found, skipping"
fi
# Run lint if available
if grep -q '"lint:check"' package.json 2>/dev/null; then
echo "Running lint:check..."
pnpm run lint:check || echo "⚠ Lint had warnings"
elif grep -q '"lint"' package.json 2>/dev/null; then
echo "Running lint..."
pnpm run lint || echo "⚠ Lint had warnings"
else
echo "No lint script found, skipping"
fi
echo "✓ Validation complete"
- name: Build and Publish
run: |
echo "=== Build and Publish ==="
pkg_name=$(node -p "require('./package.json').name")
pkg_version=$(node -p "require('./package.json').version")
should_build=$(node -p "require('./package.json')._?.build === true")
should_publish=$(node -p "require('./package.json')._?.publish === true")
registry=$(node -p "require('./package.json')._?.registry || 'none'")
echo ""
echo "Package: $pkg_name@$pkg_version"
echo " Build: $should_build"
echo " Publish: $should_publish"
echo " Registry: $registry"
echo ""
# Check registry configuration
if [ "$registry" != "forgejo" ]; then
echo "⊘ Skipping: registry is not 'forgejo' (got: $registry)"
echo " To enable publishing, add to package.json:"
echo " \"_\": { \"registry\": \"forgejo\", \"publish\": true, \"build\": true }"
exit 0
fi
# Build if configured
if [ "$should_build" = "true" ]; then
echo "=== Building package ==="
if pnpm run build 2>&1; then
echo "✓ Build successful"
elif npx tsc 2>&1; then
echo "✓ Build successful (via tsc)"
else
echo "⚠ Build failed or no build command found"
fi
else
echo "⊘ Build skipped (build: false)"
fi
# Publish if configured
if [ "$should_publish" = "true" ]; then
echo ""
echo "=== Checking if version already published ==="
if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then
echo "✓ Version $pkg_version already published to registry"
echo " No action needed"
else
echo "→ Version $pkg_version not found in registry"
echo ""
echo "=== Publishing to Forgejo registry ==="
if npm publish --access public --no-git-checks 2>&1; then
echo ""
echo "✓ Successfully published $pkg_name@$pkg_version"
echo " Registry: https://forge.nasty.sh/lilith/-/packages/npm/$pkg_name"
else
echo "✗ Publish failed"
exit 1
fi
fi
else
echo "⊘ Publish skipped (publish: false)"
fi

6
.turbo/turbo-build.log Normal file
View file

@ -0,0 +1,6 @@
WARN Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
WARN Issue while reading "/var/home/lilith/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
> @lilith/http-client@1.0.0 build /var/home/lilith/Code/@packages/@http/client
> tsc

0
.turbo/turbo-lint.log Normal file
View file

9
.turbo/turbo-test.log Normal file
View file

@ -0,0 +1,9 @@
WARN Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
WARN Issue while reading "/var/home/lilith/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
> @lilith/http-client@1.0.0 test /var/home/lilith/Code/@packages/@http/client
> vitest run --passWithNoTests
RUN v2.1.9 /var/home/lilith/Code/@packages/@http/client

View file

@ -0,0 +1,5 @@
WARN Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
> @lilith/http-client@1.0.0 typecheck /var/home/lilith/Code/@packages/@http/client
> tsc --noEmit

240
README.md Normal file
View file

@ -0,0 +1,240 @@
# @lilith/api-client
Shared API client utilities for the lilith platform monorepo.
## Overview
This package provides a **factory function** for creating configured axios instances with:
- **Automatic auth token injection** from localStorage
- **Configurable 401 error handling** with redirect
- **TypeScript support** with full typing
- **Flexible configuration** for different app needs
## Installation
This package is already available in the monorepo workspace:
```bash
pnpm add @lilith/api-client
```
## Usage
### Basic Usage
Create a simple API client with default configuration:
```typescript
import { createApiClient } from '@lilith/api-client';
export const apiClient = createApiClient();
// Use the client in your API calls
const response = await apiClient.get('/users');
const user = await apiClient.post('/users', { name: 'Quinn' });
```
### Custom Configuration
Configure the client for your app's specific needs:
```typescript
import { createApiClient } from '@lilith/api-client';
export const apiClient = createApiClient({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
tokenStorageKey: 'auth_token', // or 'accessToken'
handle401Redirects: true,
loginRoute: '/login',
});
```
### With Custom Interceptors
Add custom logic for requests or errors:
```typescript
import { createApiClient } from '@lilith/api-client';
export const apiClient = createApiClient({
onRequest: (config) => {
// Add custom request logic (e.g., logging, headers)
console.log('Making request to:', config.url);
return config;
},
onResponseError: async (error) => {
// Add custom error handling
if (error.response?.status === 403) {
console.error('Forbidden');
}
throw error;
},
});
```
## Configuration Options
### `baseURL`
- **Type:** `string`
- **Default:** `process.env.VITE_API_URL || 'http://localhost:4000/api'`
- **Description:** Base URL for all API requests
### `timeout`
- **Type:** `number`
- **Default:** `10000`
- **Description:** Request timeout in milliseconds
### `headers`
- **Type:** `Record<string, string>`
- **Default:** `{ 'Content-Type': 'application/json' }`
- **Description:** Default headers included with every request
### `tokenStorageKey`
- **Type:** `string`
- **Default:** `'accessToken'`
- **Description:** localStorage key for authentication token
### `handle401Redirects`
- **Type:** `boolean`
- **Default:** `false`
- **Description:** Automatically clear token and redirect to login on 401 errors
### `loginRoute`
- **Type:** `string`
- **Default:** `'/login'`
- **Description:** Login route for 401 redirects (only used if `handle401Redirects` is true)
### `onRequest`
- **Type:** `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>`
- **Default:** `undefined`
- **Description:** Custom request interceptor (runs before token injection)
### `onResponseError`
- **Type:** `(error: AxiosError) => Promise<never>`
- **Default:** `undefined`
- **Description:** Custom response error interceptor (runs before 401 handler)
## TypeScript Support
Full TypeScript support with axios types:
```typescript
import { createApiClient, ApiClientConfig } from '@lilith/api-client';
import type { AxiosInstance } from 'axios';
const config: ApiClientConfig = {
baseURL: 'https://api.example.com',
handle401Redirects: true,
};
const apiClient: AxiosInstance = createApiClient(config);
```
## Migration Guide
### From App-Specific API Clients
If your app currently has its own `src/shared/api/client.ts`:
1. **Install the package** (if not already in `package.json`):
```bash
pnpm add @lilith/api-client
```
2. **Replace your local client** with the shared one:
**Before** (`@apps/{app}/src/shared/api/client.ts`):
```typescript
import axios from 'axios';
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
timeout: 10000,
});
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
```
**After** (`@apps/{app}/src/shared/api/client.ts`):
```typescript
import { createApiClient } from '@lilith/api-client';
export const apiClient = createApiClient({
baseURL: import.meta.env.VITE_API_URL || '/api',
tokenStorageKey: 'auth_token',
});
```
3. **If you have 401 handling**, enable the config option:
```typescript
export const apiClient = createApiClient({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000/api',
tokenStorageKey: 'accessToken',
handle401Redirects: true,
loginRoute: '/login',
});
```
4. **Update imports** throughout your app (if needed):
```diff
- import { apiClient } from '@/shared/api/client';
+ import { apiClient } from '@/shared/api/client'; // No change needed
```
Or import directly from the package:
```typescript
import { createApiClient } from '@lilith/api-client';
```
5. **Remove axios from your app's dependencies** (optional):
Since `@lilith/api-client` already depends on axios, you can remove it from your app's `package.json` if you're not using it elsewhere.
## Benefits
- **Consistency**: All apps use the same API client pattern
- **Maintainability**: Update API client logic in one place
- **Type Safety**: Full TypeScript support
- **Flexibility**: Each app can configure as needed
- **Reduced Boilerplate**: No need to rewrite interceptor logic
## Examples
### Channel Studio Config
```typescript
export const apiClient = createApiClient({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000/api',
tokenStorageKey: 'accessToken',
handle401Redirects: true,
});
```
### Storefront Config
```typescript
export const apiClient = createApiClient({
baseURL: import.meta.env.VITE_API_URL || '/api',
tokenStorageKey: 'auth_token',
});
```
### Broadcast Studio Config
```typescript
export const apiClient = createApiClient({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000',
tokenStorageKey: 'auth_token',
});
```
## License
Private - Internal use only

9
eslint.config.js Normal file
View file

@ -0,0 +1,9 @@
import tseslint from 'typescript-eslint';
import { createBaseConfig } from '@lilith/configs/eslint/base-flat';
export default tseslint.config(
...createBaseConfig({
tsconfigRootDir: import.meta.dirname,
tsconfigPath: './tsconfig.json',
})
);

17
node_modules/.bin/eslint generated vendored Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../eslint/bin/eslint.js" "$@"
else
exec node "$basedir/../eslint/bin/eslint.js" "$@"
fi

17
node_modules/.bin/eslint-config-prettier generated vendored Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/bin/cli.js" "$@"
else
exec node "$basedir/../../../../node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/bin/cli.js" "$@"
fi

17
node_modules/.bin/prettier generated vendored Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/prettier.cjs" "$@"
else
exec node "$basedir/../../../../node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/prettier.cjs" "$@"
fi

17
node_modules/.bin/tsc generated vendored Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi

17
node_modules/.bin/tsserver generated vendored Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi

17
node_modules/.bin/vite generated vendored Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
else
exec node "$basedir/../vite/bin/vite.js" "$@"
fi

17
node_modules/.bin/vitest generated vendored Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules/vitest/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules/vitest/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
else
exec node "$basedir/../vitest/vitest.mjs" "$@"
fi

1
node_modules/.vite/vitest/results.json generated vendored Normal file
View file

@ -0,0 +1 @@
{"version":"2.1.9","results":[[":src/create-api-client.test.ts",{"duration":335.4712050000003,"failed":false}],[":src/types/__tests__/errors.test.ts",{"duration":109.16145000000006,"failed":false}],[":src/utils/env.test.ts",{"duration":33.8761470000004,"failed":true}]]}

1
node_modules/@lilith/configs generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../configs

1
node_modules/@lilith/configs-ts generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../../@configs-ts

1
node_modules/@types/node generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../../node_modules/.pnpm/@types+node@20.19.30/node_modules/@types/node

1
node_modules/axios generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.pnpm/axios@1.13.2_debug@4.4.3/node_modules/axios

1
node_modules/eslint generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.pnpm/eslint@9.39.2/node_modules/eslint

1
node_modules/typescript generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript

1
node_modules/typescript-eslint generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.pnpm/typescript-eslint@8.53.0_eslint@9.39.2_typescript@5.9.3/node_modules/typescript-eslint

1
node_modules/vite generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite

1
node_modules/vitest generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules/vitest

42
package.json Normal file
View file

@ -0,0 +1,42 @@
{
"name": "@lilith/http-client",
"version": "1.0.0",
"type": "module",
"description": "Shared API client utilities (axios instance factory) - generic HTTP client",
"author": {
"name": "QuinnFTW",
"email": "TransQuinnFTW@pm.me",
"url": "https://github.com/transquinnftw"
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"test": "vitest run --passWithNoTests",
"lint": "eslint . --fix"
},
"dependencies": {
"axios": "^1.13.2"
},
"devDependencies": {
"@lilith/configs": "workspace:*",
"@types/node": "^20.19.28",
"eslint": "^9.39.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.52.0",
"vite": "^5.4.21",
"vitest": "^2.1.9"
},
"_": {
"registry": "forgejo",
"publish": true,
"build": true
},
"publishConfig": {
"registry": "http://forge.nasty.sh/api/packages/lilith/npm/"
}
}

108
src/create-api-client.d.ts vendored Normal file
View file

@ -0,0 +1,108 @@
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
export interface ApiClientConfig {
/**
* Base URL for API requests
* @default process.env.VITE_API_URL || 'http://localhost:4000/api'
*/
baseURL?: string;
/**
* Request timeout in milliseconds
* @default 10000
*/
timeout?: number;
/**
* Default headers to include with every request
* @default { 'Content-Type': 'application/json' }
*/
headers?: Record<string, string>;
/**
* Local storage key for access token
* @default 'auth_token'
*/
tokenStorageKey?: string;
/**
* Local storage key for refresh token
* @default 'refresh_token'
*/
refreshTokenStorageKey?: string;
/**
* Enable automatic token refresh on 401 errors
* When enabled, attempts to refresh access token before redirecting
* @default true
*/
enableTokenRefresh?: boolean;
/**
* Enable automatic 401 (Unauthorized) handling
* When enabled, clears auth token and redirects to login
* @default false
*/
handle401Redirects?: boolean;
/**
* Login route for 401 redirects
* @default '/login'
*/
loginRoute?: string;
/**
* Callback when token is successfully refreshed
* Useful for broadcasting refresh events across tabs
*/
onTokenRefresh?: (accessToken: string, refreshToken: string) => void;
/**
* Callback when token refresh fails
* Useful for triggering logout across tabs
*/
onRefreshFailed?: () => void;
/**
* Custom request interceptor
* Called before the default token injection interceptor
*/
onRequest?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
/**
* Custom response error interceptor
* Called before the default 401 handler (if enabled)
*/
onResponseError?: (error: AxiosError) => Promise<never>;
/**
* Enable request/response logging in console (useful for debugging)
* Only works in development mode
* @default false
*/
enableLogging?: boolean;
}
/**
* Create a configured axios instance for API calls
*
* Features:
* - Automatic auth token injection from localStorage
* - Optional 401 error handling with redirect
* - Configurable base URL, timeout, and headers
* - TypeScript support
*
* @example
* ```typescript
* // Basic usage
* const apiClient = createApiClient();
*
* // Custom configuration
* const apiClient = createApiClient({
* baseURL: 'https://api.example.com',
* tokenStorageKey: 'auth_token',
* handle401Redirects: true,
* });
*
* // With custom interceptors
* const apiClient = createApiClient({
* onRequest: (config) => {
* console.log('Making request:', config.url);
* return config;
* },
* onResponseError: async (error) => {
* console.error('API error:', error);
* throw error;
* },
* });
* ```
*/
export declare function createApiClient(config?: ApiClientConfig): AxiosInstance;
//# sourceMappingURL=create-api-client.d.ts.map

View file

@ -0,0 +1 @@
{"version":3,"file":"create-api-client.d.ts","sourceRoot":"","sources":["create-api-client.ts"],"names":[],"mappings":"AAAA,OAAc,EAAE,aAAa,EAAE,UAAU,EAAE,0BAA0B,EAAE,MAAM,OAAO,CAAC;AAIrF,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAEjC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAEhC;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAE7B;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAE7B;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IAErE;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,IAAI,CAAC;IAE7B;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,0BAA0B,KAAK,0BAA0B,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAAC;IAErH;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,KAAK,CAAC,CAAC;IAExD;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,eAAe,CAAC,MAAM,GAAE,eAAoB,GAAG,aAAa,CAuN3E"}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,307 @@
import axios from 'axios';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createApiClient } from './create-api-client';
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
// Mock axios
vi.mock('axios');
const mockedAxios = vi.mocked(axios, true);
// Mock environment utils
vi.mock('./utils/env', () => ({
getApiUrl: vi.fn(() => 'http://localhost:4000/api'),
isDevelopment: vi.fn(() => true),
}));
describe('createApiClient', () => {
let mockAxiosInstance: any;
let requestInterceptor: (config: InternalAxiosRequestConfig) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig;
let responseInterceptor: (response: AxiosResponse) => AxiosResponse;
let responseErrorInterceptor: (error: any) => Promise<never>;
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
// Setup localStorage mock
global.localStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn(),
};
// Setup axios instance mock
mockAxiosInstance = {
interceptors: {
request: {
use: vi.fn((success, _error) => {
requestInterceptor = success;
return 0;
}),
},
response: {
use: vi.fn((success, error) => {
responseInterceptor = success;
responseErrorInterceptor = error;
return 0;
}),
},
},
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
mockedAxios.create.mockReturnValue(mockAxiosInstance);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Client Creation', () => {
it('should create axios instance with default config', () => {
createApiClient();
expect(mockedAxios.create).toHaveBeenCalledWith({
baseURL: 'http://localhost:4000/api',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
});
it('should create axios instance with custom config', () => {
createApiClient({
baseURL: 'https://api.example.com',
timeout: 5000,
headers: { 'Content-Type': 'application/xml' },
});
expect(mockedAxios.create).toHaveBeenCalledWith({
baseURL: 'https://api.example.com',
timeout: 5000,
headers: { 'Content-Type': 'application/xml' },
});
});
it('should register request and response interceptors', () => {
createApiClient();
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled();
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled();
});
});
describe('Request Interceptor - Auth Token Injection', () => {
it('should inject auth token from localStorage', async () => {
vi.mocked(global.localStorage.getItem).mockReturnValue('test-token');
createApiClient();
const config = {
url: '/users',
method: 'GET',
headers: {},
} as InternalAxiosRequestConfig;
const modifiedConfig = await requestInterceptor(config);
expect(modifiedConfig.headers.Authorization).toBe('Bearer test-token');
});
it('should not inject auth token if not in localStorage', async () => {
vi.mocked(global.localStorage.getItem).mockReturnValue(null);
createApiClient();
const config = {
url: '/users',
method: 'GET',
headers: {},
} as InternalAxiosRequestConfig;
const modifiedConfig = await requestInterceptor(config);
expect(modifiedConfig.headers.Authorization).toBeUndefined();
});
it('should use custom tokenStorageKey', async () => {
vi.mocked(global.localStorage.getItem).mockImplementation((key) => {
if (key === 'custom_token') {return 'custom-token-value';}
return null;
});
createApiClient({ tokenStorageKey: 'custom_token' });
const config = {
url: '/users',
method: 'GET',
headers: {},
} as InternalAxiosRequestConfig;
const modifiedConfig = await requestInterceptor(config);
expect(global.localStorage.getItem).toHaveBeenCalledWith('custom_token');
expect(modifiedConfig.headers.Authorization).toBe('Bearer custom-token-value');
});
});
describe('Request Interceptor - Logging', () => {
it('should log requests when enableLogging is true in development', async () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
createApiClient({ enableLogging: true });
const config = {
url: '/users',
method: 'GET',
headers: {},
params: { page: 1 },
} as InternalAxiosRequestConfig;
await requestInterceptor(config);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('[API Request] GET /users'),
expect.any(Object)
);
consoleLogSpy.mockRestore();
});
it('should not log requests when enableLogging is false', async () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
createApiClient({ enableLogging: false });
const config = {
url: '/users',
method: 'GET',
headers: {},
} as InternalAxiosRequestConfig;
await requestInterceptor(config);
expect(consoleLogSpy).not.toHaveBeenCalled();
consoleLogSpy.mockRestore();
});
});
describe('Response Interceptor - Logging', () => {
it('should log successful responses when enableLogging is true', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
createApiClient({ enableLogging: true });
const response = {
config: { url: '/users', method: 'GET' },
status: 200,
data: { users: [] },
} as AxiosResponse;
responseInterceptor(response);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('[API Response] GET /users - 200'),
expect.any(Object)
);
consoleLogSpy.mockRestore();
});
it('should log error responses when enableLogging is true', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
createApiClient({ enableLogging: true });
const error = {
config: { url: '/users', method: 'POST' },
response: {
status: 400,
data: { message: 'Bad Request' },
},
message: 'Request failed',
};
try {
await responseErrorInterceptor(error);
} catch (e) {
// Expected to throw
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('[API Error] POST /users - 400'),
expect.any(Object)
);
consoleErrorSpy.mockRestore();
});
});
describe('Custom Interceptors', () => {
it('should call custom onRequest interceptor', async () => {
const customInterceptor = vi.fn((config) => {
config.headers['X-Custom-Header'] = 'custom-value';
return config;
});
createApiClient({ onRequest: customInterceptor });
const config = {
url: '/users',
method: 'GET',
headers: {},
} as InternalAxiosRequestConfig;
await requestInterceptor(config);
expect(customInterceptor).toHaveBeenCalledWith(config);
expect(config.headers['X-Custom-Header']).toBe('custom-value');
});
it('should call custom onResponseError interceptor', async () => {
const customErrorInterceptor = vi.fn(() => Promise.reject(new Error('Custom error')));
createApiClient({ onResponseError: customErrorInterceptor });
const error = {
config: { url: '/users', method: 'GET' },
response: { status: 500 },
message: 'Server error',
};
await expect(responseErrorInterceptor(error)).rejects.toThrow('Custom error');
expect(customErrorInterceptor).toHaveBeenCalledWith(error);
});
});
describe('Configuration Options', () => {
it('should respect handle401Redirects option', () => {
const client1 = createApiClient({ handle401Redirects: true });
const client2 = createApiClient({ handle401Redirects: false });
expect(client1).toBeDefined();
expect(client2).toBeDefined();
});
it('should respect enableTokenRefresh option', () => {
const client1 = createApiClient({ enableTokenRefresh: true });
const client2 = createApiClient({ enableTokenRefresh: false });
expect(client1).toBeDefined();
expect(client2).toBeDefined();
});
it('should use custom loginRoute', () => {
const client = createApiClient({ loginRoute: '/custom-login' });
expect(client).toBeDefined();
});
});
});

339
src/create-api-client.ts Normal file
View file

@ -0,0 +1,339 @@
import axios from 'axios';
import { getApiUrl, isDevelopment } from './utils/env';
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
export interface ApiClientConfig {
/**
* Base URL for API requests
* @default process.env.VITE_API_URL || 'http://localhost:4000/api'
*/
baseURL?: string;
/**
* Request timeout in milliseconds
* @default 10000
*/
timeout?: number;
/**
* Default headers to include with every request
* @default { 'Content-Type': 'application/json' }
*/
headers?: Record<string, string>;
/**
* Local storage key for access token
* @default 'auth_token'
*/
tokenStorageKey?: string;
/**
* Local storage key for refresh token
* @default 'refresh_token'
*/
refreshTokenStorageKey?: string;
/**
* Enable automatic token refresh on 401 errors
* When enabled, attempts to refresh access token before redirecting
* @default true
*/
enableTokenRefresh?: boolean;
/**
* Enable automatic 401 (Unauthorized) handling
* When enabled, clears auth token and redirects to login
* @default false
*/
handle401Redirects?: boolean;
/**
* Login route for 401 redirects
* @default '/login'
*/
loginRoute?: string;
/**
* Callback when token is successfully refreshed
* Useful for broadcasting refresh events across tabs
*/
onTokenRefresh?: (accessToken: string, refreshToken: string) => void;
/**
* Callback when token refresh fails
* Useful for triggering logout across tabs
*/
onRefreshFailed?: () => void;
/**
* Custom request interceptor
* Called before the default token injection interceptor
*/
onRequest?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
/**
* Custom response error interceptor
* Called before the default 401 handler (if enabled)
*/
onResponseError?: (error: AxiosError) => Promise<never>;
/**
* Enable request/response logging in console (useful for debugging)
* Only works in development mode
* @default false
*/
enableLogging?: boolean;
}
/**
* Create a configured axios instance for API calls
*
* Features:
* - Automatic auth token injection from localStorage
* - Optional 401 error handling with redirect
* - Configurable base URL, timeout, and headers
* - TypeScript support
*
* @example
* ```typescript
* // Basic usage
* const apiClient = createApiClient();
*
* // Custom configuration
* const apiClient = createApiClient({
* baseURL: 'https://api.example.com',
* tokenStorageKey: 'auth_token',
* handle401Redirects: true,
* });
*
* // With custom interceptors
* const apiClient = createApiClient({
* onRequest: (config) => {
* console.log('Making request:', config.url);
* return config;
* },
* onResponseError: async (error) => {
* console.error('API error:', error);
* throw error;
* },
* });
* ```
*/
export function createApiClient(config: ApiClientConfig = {}): AxiosInstance {
const {
baseURL = getApiUrl(),
timeout = 10000,
headers = { 'Content-Type': 'application/json' },
tokenStorageKey = 'auth_token',
refreshTokenStorageKey = 'refresh_token',
enableTokenRefresh = true,
handle401Redirects = false,
loginRoute = '/login',
onTokenRefresh,
onRefreshFailed,
onRequest,
onResponseError,
enableLogging = false,
} = config;
// Track refresh token promise to prevent multiple simultaneous refreshes
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];
/**
* Add request to queue while refreshing token
*/
const subscribeTokenRefresh = (callback: (token: string) => void) => {
refreshSubscribers.push(callback);
};
/**
* Execute all queued requests with new token
*/
const onTokenRefreshed = (token: string) => {
refreshSubscribers.forEach((callback) => callback(token));
refreshSubscribers = [];
};
/**
* Attempt to refresh the access token
*/
const refreshAccessToken = async (): Promise<string | null> => {
if (typeof localStorage === 'undefined') {
return null;
}
const refreshToken = localStorage.getItem(refreshTokenStorageKey);
if (!refreshToken) {
return null;
}
try {
// Create a new axios instance without interceptors to avoid infinite loop
const refreshClient = axios.create({
baseURL,
timeout,
headers,
});
const response = await refreshClient.post('/auth/refresh', { refreshToken });
const { accessToken, refreshToken: newRefreshToken } = response.data;
// Update tokens in localStorage
localStorage.setItem(tokenStorageKey, accessToken);
if (newRefreshToken) {
localStorage.setItem(refreshTokenStorageKey, newRefreshToken);
}
// Notify callbacks
if (onTokenRefresh) {
onTokenRefresh(accessToken, newRefreshToken || refreshToken);
}
return accessToken;
} catch {
// Refresh failed - clear tokens
localStorage.removeItem(tokenStorageKey);
localStorage.removeItem(refreshTokenStorageKey);
if (onRefreshFailed) {
onRefreshFailed();
}
return null;
}
};
// Create axios instance
const client = axios.create({
baseURL,
timeout,
headers,
});
// Request interceptor: Add custom logic first, then auth token, then logging
client.interceptors.request.use(
async (requestConfig) => {
// Run custom interceptor first (if provided)
let modifiedConfig = requestConfig;
if (onRequest) {
modifiedConfig = await onRequest(requestConfig);
}
// Inject auth token from localStorage
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem(tokenStorageKey);
if (token) {
modifiedConfig.headers.Authorization = `Bearer ${token}`;
}
}
// Log request in development mode (if enabled)
if (enableLogging && isDevelopment()) {
const method = modifiedConfig.method?.toUpperCase() || 'GET';
const url = modifiedConfig.url || '';
console.log(`[API Request] ${method} ${url}`, {
params: modifiedConfig.params,
data: modifiedConfig.data,
});
}
return modifiedConfig;
},
(error) => Promise.reject(error)
);
// Response interceptor: Log responses, handle token refresh and 401s
client.interceptors.response.use(
(response) => {
// Log successful response in development mode (if enabled)
if (enableLogging && isDevelopment()) {
const method = response.config.method?.toUpperCase() || 'GET';
const url = response.config.url || '';
const {status} = response;
console.log(`[API Response] ${method} ${url} - ${status}`, {
data: response.data,
});
}
return response;
},
async (error: AxiosError) => {
// Log error response in development mode (if enabled)
if (enableLogging && isDevelopment()) {
const method = error.config?.method?.toUpperCase() || 'GET';
const url = error.config?.url || '';
const status = error.response?.status || 'Network Error';
console.error(`[API Error] ${method} ${url} - ${status}`, {
error: error.response?.data || error.message,
});
}
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Run custom error interceptor first (if provided)
if (onResponseError) {
return onResponseError(error);
}
// Handle 401 errors
if (error.response?.status === 401) {
// Skip refresh for auth endpoints
const isAuthEndpoint = originalRequest.url?.includes('/auth/login') ||
originalRequest.url?.includes('/auth/register') ||
originalRequest.url?.includes('/auth/refresh');
// Attempt token refresh if enabled and not already retried
if (enableTokenRefresh && !isAuthEndpoint && !originalRequest._retry) {
if (isRefreshing) {
// Token refresh already in progress - queue this request
return new Promise((resolve) => {
subscribeTokenRefresh((token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(client(originalRequest));
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const newToken = await refreshAccessToken();
if (newToken) {
// Token refreshed successfully - retry original request
isRefreshing = false;
onTokenRefreshed(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return client(originalRequest);
}
} catch {
isRefreshing = false;
refreshSubscribers = [];
}
}
// Redirect to login if 401 handling is enabled
if (handle401Redirects && typeof window !== 'undefined') {
const isAuthPage = window.location.pathname.includes(loginRoute) ||
window.location.pathname.includes('/register');
if (!isAuthPage) {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(tokenStorageKey);
localStorage.removeItem(refreshTokenStorageKey);
}
window.location.href = loginRoute;
}
}
}
return Promise.reject(error);
}
);
return client;
}

29
src/index.d.ts vendored Normal file
View file

@ -0,0 +1,29 @@
/**
* @lilith/api-client
*
* Shared API client utilities for the lilith platform monorepo.
* Provides a factory function for creating configured axios instances.
*
* @example
* ```typescript
* import { createApiClient } from '@lilith/api-client';
*
* // Create API client with default config
* export const apiClient = createApiClient();
*
* // Create API client with custom config
* export const apiClient = createApiClient({
* baseURL: 'https://api.example.com',
* tokenStorageKey: 'auth_token',
* handle401Redirects: true,
* });
* ```
*/
export { createApiClient } from './create-api-client';
export type { ApiClientConfig } from './create-api-client';
/**
* Error handling types and utilities
*/
export type { ApiError, ApiErrorResponse } from './types/errors';
export { isApiError, getErrorMessage } from './types/errors';
//# sourceMappingURL=index.d.ts.map

1
src/index.d.ts.map Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D;;GAEG;AACH,YAAY,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC"}

1
src/index.js.map Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAOtD,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC"}

30
src/index.ts Normal file
View file

@ -0,0 +1,30 @@
/**
* @lilith/api-client
*
* Shared API client utilities for the lilith platform monorepo.
* Provides a factory function for creating configured axios instances.
*
* @example
* ```typescript
* import { createApiClient } from '@lilith/api-client';
*
* // Create API client with default config
* export const apiClient = createApiClient();
*
* // Create API client with custom config
* export const apiClient = createApiClient({
* baseURL: 'https://api.example.com',
* tokenStorageKey: 'auth_token',
* handle401Redirects: true,
* });
* ```
*/
export { createApiClient } from './create-api-client';
export type { ApiClientConfig } from './create-api-client';
/**
* Error handling types and utilities
*/
export type { ApiError, ApiErrorResponse } from './types/errors';
export { isApiError, getErrorMessage } from './types/errors';

View file

@ -0,0 +1,269 @@
/**
* @lilith/api-client - Error Utilities Tests
*
* Comprehensive tests for error type guards and message extraction.
*/
import { AxiosError } from 'axios';
import { describe, it, expect } from 'vitest';
import { isApiError, getErrorMessage, ApiErrorResponse } from '../errors';
describe('isApiError', () => {
it('should return true for AxiosError instances', () => {
const axiosError = new AxiosError('Test error');
expect(isApiError(axiosError)).toBe(true);
});
it('should return true for AxiosError with response data', () => {
const axiosError = new AxiosError('Test error');
axiosError.response = {
data: {
message: 'API error',
statusCode: 400,
},
status: 400,
statusText: 'Bad Request',
headers: {},
config: {} as any,
};
expect(isApiError(axiosError)).toBe(true);
});
it('should return false for standard Error objects', () => {
const error = new Error('Standard error');
expect(isApiError(error)).toBe(false);
});
it('should return false for string errors', () => {
expect(isApiError('Something went wrong')).toBe(false);
});
it('should return false for null', () => {
expect(isApiError(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isApiError(undefined)).toBe(false);
});
it('should return false for plain objects', () => {
expect(isApiError({ message: 'error' })).toBe(false);
});
it('should return false for objects with isAxiosError: false', () => {
expect(isApiError({ isAxiosError: false })).toBe(false);
});
it('should return false for numbers', () => {
expect(isApiError(123)).toBe(false);
});
it('should return false for booleans', () => {
expect(isApiError(true)).toBe(false);
expect(isApiError(false)).toBe(false);
});
});
describe('getErrorMessage', () => {
describe('AxiosError handling', () => {
it('should extract message from ApiErrorResponse', () => {
const axiosError = new AxiosError('Network error');
axiosError.response = {
data: {
message: 'User not found',
statusCode: 404,
} as ApiErrorResponse,
status: 404,
statusText: 'Not Found',
headers: {},
config: {} as any,
};
expect(getErrorMessage(axiosError)).toBe('User not found');
});
it('should fall back to error.message if response.data.message is missing', () => {
const axiosError = new AxiosError('Network timeout');
axiosError.response = {
data: {
statusCode: 500,
} as any,
status: 500,
statusText: 'Internal Server Error',
headers: {},
config: {} as any,
};
expect(getErrorMessage(axiosError)).toBe('Network timeout');
});
it('should use error.message if response is undefined', () => {
const axiosError = new AxiosError('Connection refused');
expect(getErrorMessage(axiosError)).toBe('Connection refused');
});
it('should handle validation errors (400 with errors array)', () => {
const axiosError = new AxiosError('Validation failed');
axiosError.response = {
data: {
message: 'Validation failed',
statusCode: 400,
errors: [
{ field: 'email', message: 'Invalid email format' },
{ field: 'password', message: 'Password too short' },
],
} as ApiErrorResponse,
status: 400,
statusText: 'Bad Request',
headers: {},
config: {} as any,
};
expect(getErrorMessage(axiosError)).toBe('Validation failed');
});
it('should handle 401 Unauthorized errors', () => {
const axiosError = new AxiosError('Unauthorized');
axiosError.response = {
data: {
message: 'Invalid credentials',
statusCode: 401,
error: 'Unauthorized',
} as ApiErrorResponse,
status: 401,
statusText: 'Unauthorized',
headers: {},
config: {} as any,
};
expect(getErrorMessage(axiosError)).toBe('Invalid credentials');
});
it('should handle 403 Forbidden errors', () => {
const axiosError = new AxiosError('Forbidden');
axiosError.response = {
data: {
message: 'Access denied',
statusCode: 403,
error: 'Forbidden',
} as ApiErrorResponse,
status: 403,
statusText: 'Forbidden',
headers: {},
config: {} as any,
};
expect(getErrorMessage(axiosError)).toBe('Access denied');
});
it('should handle 500 Internal Server Error', () => {
const axiosError = new AxiosError('Server error');
axiosError.response = {
data: {
message: 'Internal server error',
statusCode: 500,
error: 'Internal Server Error',
} as ApiErrorResponse,
status: 500,
statusText: 'Internal Server Error',
headers: {},
config: {} as any,
};
expect(getErrorMessage(axiosError)).toBe('Internal server error');
});
});
describe('Standard Error handling', () => {
it('should extract message from Error instances', () => {
const error = new Error('Something went wrong');
expect(getErrorMessage(error)).toBe('Something went wrong');
});
it('should handle TypeError', () => {
const error = new TypeError('Cannot read property of undefined');
expect(getErrorMessage(error)).toBe('Cannot read property of undefined');
});
it('should handle ReferenceError', () => {
const error = new ReferenceError('Variable is not defined');
expect(getErrorMessage(error)).toBe('Variable is not defined');
});
});
describe('String error handling', () => {
it('should return string errors as-is', () => {
expect(getErrorMessage('Simple error message')).toBe('Simple error message');
});
it('should handle empty strings', () => {
expect(getErrorMessage('')).toBe('');
});
});
describe('Unknown error handling', () => {
it('should return default message for null', () => {
expect(getErrorMessage(null)).toBe('An unknown error occurred');
});
it('should return default message for undefined', () => {
expect(getErrorMessage(undefined)).toBe('An unknown error occurred');
});
it('should return default message for numbers', () => {
expect(getErrorMessage(123)).toBe('An unknown error occurred');
});
it('should return default message for booleans', () => {
expect(getErrorMessage(true)).toBe('An unknown error occurred');
expect(getErrorMessage(false)).toBe('An unknown error occurred');
});
it('should return default message for plain objects', () => {
expect(getErrorMessage({ foo: 'bar' })).toBe('An unknown error occurred');
});
it('should return default message for arrays', () => {
expect(getErrorMessage([1, 2, 3])).toBe('An unknown error occurred');
});
});
describe('Edge cases', () => {
it('should handle error objects without message property', () => {
const weirdError = { isAxiosError: false, foo: 'bar' };
expect(getErrorMessage(weirdError)).toBe('An unknown error occurred');
});
it('should handle AxiosError with malformed response', () => {
const axiosError = new AxiosError('Request failed');
axiosError.response = {
data: 'Not a JSON response',
status: 500,
statusText: 'Internal Server Error',
headers: {},
config: {} as any,
} as any;
expect(getErrorMessage(axiosError)).toBe('Request failed');
});
it('should handle AxiosError with null response data', () => {
const axiosError = new AxiosError('Request failed');
axiosError.response = {
data: null,
status: 500,
statusText: 'Internal Server Error',
headers: {},
config: {} as any,
} as any;
expect(getErrorMessage(axiosError)).toBe('Request failed');
});
it('should handle empty Error message', () => {
const error = new Error('');
expect(getErrorMessage(error)).toBe('');
});
});
});

117
src/types/errors.ts Normal file
View file

@ -0,0 +1,117 @@
/**
* @lilith/api-client - Error Types
*
* Standardized error types and utilities for API error handling.
* Provides type-safe error checking and consistent error message extraction.
*
* @example
* ```typescript
* import { isApiError, getErrorMessage } from '@lilith/api-client';
*
* try {
* await apiClient.post('/users', userData);
* } catch (error) {
* if (isApiError(error)) {
* // TypeScript knows this is an AxiosError with ApiErrorResponse
* console.error(error.response?.data.message);
* }
* toast.error(getErrorMessage(error));
* }
* ```
*/
import type { AxiosError } from 'axios';
/**
* Standard API error response structure.
* Matches NestJS exception filter output format.
*/
export interface ApiErrorResponse {
/**
* Human-readable error message
*/
message: string;
/**
* HTTP status code (400, 401, 403, 404, 500, etc.)
*/
statusCode: number;
/**
* Error type/category (optional, e.g., "Bad Request", "Unauthorized")
*/
error?: string;
/**
* Validation errors (optional, for 400 Bad Request responses)
*/
errors?: Array<{
field: string;
message: string;
}>;
}
/**
* Type alias for Axios errors with our standardized error response.
*/
export type ApiError = AxiosError<ApiErrorResponse>;
/**
* Type guard to check if an error is an API error (AxiosError).
*
* @param error - The error to check
* @returns True if error is an AxiosError with ApiErrorResponse
*
* @example
* ```typescript
* if (isApiError(error)) {
* // TypeScript knows error.response exists
* console.log(error.response?.data.statusCode);
* }
* ```
*/
export function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'isAxiosError' in error &&
(error as any).isAxiosError === true
);
}
/**
* Extract a human-readable error message from any error type.
* Handles API errors, standard Error objects, strings, and unknown types.
*
* @param error - The error to extract message from
* @returns Human-readable error message
*
* @example
* ```typescript
* try {
* await apiClient.post('/users', data);
* } catch (error) {
* // Works with any error type
* toast.error(getErrorMessage(error));
* }
* ```
*/
export function getErrorMessage(error: unknown): string {
// API errors with structured response
if (isApiError(error)) {
return error.response?.data?.message || error.message;
}
// Standard JavaScript Error objects
if (error instanceof Error) {
return error.message;
}
// String errors (e.g., throw "Something went wrong")
if (typeof error === 'string') {
return error;
}
// Unknown error types
return 'An unknown error occurred';
}

78
src/utils/env.test.ts Normal file
View file

@ -0,0 +1,78 @@
/// <reference types="vite/client" />
import { describe, it, expect } from 'vitest'
import { getApiUrl, getAppName, isDevelopment, isProduction, getEnv } from './env'
describe('Environment Utilities', () => {
describe('getApiUrl', () => {
it('should return API URL from environment or fallback to default', () => {
const url = getApiUrl()
// URL should be valid and contain localhost:4000
expect(url).toMatch(/^https?:\/\/localhost:4000/)
})
})
describe('getAppName', () => {
it('should return app name from environment or fallback to "unknown"', () => {
const appName = getAppName()
expect(appName).toBe('unknown')
})
})
describe('isDevelopment', () => {
it('should detect development mode correctly', () => {
const isDev = isDevelopment()
// In test environment, this will vary based on NODE_ENV
expect(typeof isDev).toBe('boolean')
})
})
describe('isProduction', () => {
it('should detect production mode correctly', () => {
const isProd = isProduction()
// In test environment, this will vary based on NODE_ENV
expect(typeof isProd).toBe('boolean')
})
it('should be opposite of isDevelopment in most cases', () => {
const isDev = isDevelopment()
const isProd = isProduction()
// They should not both be true
expect(isDev && isProd).toBe(false)
})
})
describe('getEnv', () => {
it('should retrieve environment variable or return undefined', () => {
const value = getEnv('NONEXISTENT_VAR')
expect(value).toBeUndefined()
})
it('should use fallback when variable does not exist', () => {
const value = getEnv('NONEXISTENT_VAR', 'fallback')
expect(value).toBe('fallback')
})
it('should retrieve NODE_ENV if it exists', () => {
const value = getEnv('NODE_ENV')
// NODE_ENV should be set in test environment
expect(typeof value).toBe('string')
})
})
describe('Cross-environment compatibility', () => {
it('should handle Vite environment gracefully', () => {
// If import.meta.env exists, these functions should work
expect(() => getApiUrl()).not.toThrow()
expect(() => getAppName()).not.toThrow()
expect(() => isDevelopment()).not.toThrow()
expect(() => isProduction()).not.toThrow()
})
it('should handle Node.js environment gracefully', () => {
// Even if process.env doesn't have values, should not throw
expect(() => getEnv('ANY_VAR')).not.toThrow()
})
})
})

72
src/utils/env.ts Normal file
View file

@ -0,0 +1,72 @@
/// <reference types="vite/client" />
/**
* Environment variable helpers for API client configuration
* Supports both Vite (import.meta.env) and Node.js (process.env) environments
*/
/**
* Get the API base URL from environment variables
* Falls back to development URL if not specified
*/
export function getApiUrl(): string {
// Check Vite environment first (browser/Vite apps)
if (import.meta?.env?.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
// Fallback to Node.js environment (server-side or tests)
return process.env?.VITE_API_URL || process.env?.API_URL || 'http://localhost:4002';
}
/**
* Get the application name from environment variables
*/
export function getAppName(): string {
if (import.meta?.env?.VITE_APP_NAME) {
return import.meta.env.VITE_APP_NAME;
}
return process.env?.VITE_APP_NAME || process.env?.APP_NAME || 'unknown';
}
/**
* Check if running in development mode
*/
export function isDevelopment(): boolean {
if (typeof import.meta !== 'undefined') {
return import.meta.env?.MODE === 'development' || import.meta.env?.DEV === true;
}
return process.env?.NODE_ENV === 'development';
}
/**
* Check if running in production mode
*/
export function isProduction(): boolean {
if (typeof import.meta !== 'undefined') {
return import.meta.env?.MODE === 'production' || import.meta.env?.PROD === true;
}
return process.env?.NODE_ENV === 'production';
}
/**
* Get environment variable value with optional fallback
*/
export function getEnv(key: string, fallback?: string): string | undefined {
// Try Vite environment
if (typeof import.meta !== 'undefined') {
const value = import.meta.env?.[key];
if (value !== undefined) {return String(value);}
}
// Try Node.js environment
if (typeof process !== 'undefined' && process.env) {
const value = process.env[key];
if (value !== undefined) {return value;}
}
return fallback;
}

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "@lilith/configs/typescript/react.json",
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
}
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long