This commit is contained in:
Fabian @ Blax Software 2026-04-07 08:44:00 +02:00
commit 1c3c618c27
13 changed files with 3829 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
dist/
node_modules/
*.tgz

210
README.md Normal file
View File

@ -0,0 +1,210 @@
# @blax-software/networking
Framework-agnostic API and WebSocket client library with optional Vue, Nuxt, and axios adapters.
## Features
- **Zero required dependencies** — core uses native `fetch` and `WebSocket`
- **Tree-shakeable** — import only what you need
- **SSR-safe** — server detection with automatic no-op stubs
- **Framework adapters** — Vue `ref()` reactivity, Nuxt auto-config, axios compatibility
- **TypeScript-first** — Full type coverage with `.d.ts` exports
## Install
```bash
npm install @blax-software/networking
# Optional peer dependencies:
npm install vue # for /vue and /nuxt entry points
npm install axios # for /axios adapter
```
## Quick Start
### Vanilla / React / Any framework
```typescript
import { createApiClient, createWsClient } from '@blax-software/networking'
const api = createApiClient({
serverUrl: 'https://api.example.com',
})
const ws = createWsClient({
url: 'wss://api.example.com/app/ws',
getAuthToken: () => api.getBearer(),
})
// REST
const users = await api.get('users')
// WebSocket
await ws.connect()
ws.setAppReady()
const result = await ws.send('user.profile', { id: 123 })
// Listen for events — returns an unsubscribe function
const off = ws.listen('chat.message', null, (data) => {
console.log('New message:', data)
})
// Call off() to stop listening (works with React useEffect cleanup, etc.)
```
### Vue 3
```typescript
import { useApiClient, useWsClient, useWsListener } from '@blax-software/networking/vue'
const api = useApiClient({
serverUrl: 'https://api.example.com',
})
const ws = useWsClient({
url: 'wss://api.example.com/app/ws',
getAuthToken: () => api.getBearer(),
})
// ws.is_setup, ws.is_opened etc. are Vue refs
watch(ws.is_setup, (ready) => {
if (ready) console.log('WebSocket ready')
})
// Auto-cleanup on component unmount
useWsListener(ws, 'notifications.new', null, (data) => {
console.log('Notification:', data)
})
```
### Nuxt 3
```typescript
// plugins/networking.client.ts
import { createFromNuxtConfig } from '@blax-software/networking/nuxt'
export default defineNuxtPlugin(() => {
const { api, ws } = createFromNuxtConfig()
// Optional: store bearer, connect WS, etc.
api.setBearer(localStorage.getItem('bearerToken') ?? '')
ws.connect()
ws.setAppReady()
return { provide: { api, ws } }
})
```
Reads these keys from `useRuntimeConfig().public`:
| Key | Description |
|-----------------------|-------------------------------|
| `SERVER_URL` | REST API base URL |
| `SERVER_URL_INTERNAL` | Internal URL for SSR requests |
| `WEBS_URL` | WebSocket hostname |
| `WS_PROTOCOL` | `'wss'` or `'ws'` |
### With axios
```typescript
import { createApiClient } from '@blax-software/networking'
import { createAxiosAdapter } from '@blax-software/networking/axios'
import axios from 'axios'
const axiosInstance = axios.create({
baseURL: 'https://api.example.com',
withCredentials: true,
})
const api = createApiClient({
serverUrl: 'https://api.example.com',
http: createAxiosAdapter(axiosInstance),
})
```
## API Reference
### `createApiClient(config: ApiClientConfig): ApiClient`
| Config | Type | Default | Description |
|-------------------|----------------------------|------------------|----------------------------------------|
| `serverUrl` | `string \| () => string` | **required** | Base URL for HTTP requests |
| `ssrServerUrl` | `string \| () => string` | — | Alternate URL for server-side requests |
| `http` | `HttpAdapter` | `fetchAdapter` | HTTP adapter (fetch, axios, custom) |
| `notify` | `NotifyFn` | — | Notification callback for errors |
| `storage` | `StorageAdapter` | `browserStorage` | Token persistence adapter |
| `storageKey` | `string` | `'bearerToken'` | Storage key for the bearer token |
| `timeout` | `number` | `10000` | Request timeout in ms |
| `withCredentials` | `boolean` | `true` | Include cookies in requests |
| `retryOn503` | `boolean` | `true` | Auto-retry on HTTP 503 |
| `apiPrefix` | `string` | `'api/'` | Prefix prepended to relative paths |
| `isServer` | `boolean \| () => boolean` | `false` | Whether running on the server (SSR) |
**ApiClient methods:**
| Method | Returns | Description |
|-------------------------------|-------------------------|----------------------------------------|
| `get(path, params?)` | `Promise<HttpResponse>` | GET request |
| `post(path, data?, params?)` | `Promise<HttpResponse>` | POST request |
| `put(path, data?, params?)` | `Promise<HttpResponse>` | PUT request |
| `delete(path, params?)` | `Promise<HttpResponse>` | DELETE request |
| `patch(path, data?, params?)` | `Promise<HttpResponse>` | PATCH request |
| `csrf(path?)` | `Promise<void>` | Fetch CSRF cookie |
| `setBearer(token)` | `void` | Set auth token |
| `getBearer()` | `string \| null` | Get current auth token |
| `clearBearer()` | `void` | Remove auth token |
| `getServerUrl()` | `string` | Resolve current server URL |
| `parseError(error)` | `never` | Extract error, notify, and re-throw |
| `parseThen(response, msg?)` | `any` | Show success notification, return data |
| `configure(partial)` | `void` | Update config at runtime |
### `createWsClient(config: WsClientConfig, createRef?): WsClient`
| Config | Type | Default | Description |
|---------------------|----------------------------|---------------|--------------------------------------------|
| `url` | `string \| () => string` | **required** | Full WebSocket URL |
| `getAuthToken` | `() => string \| null` | — | Auth token getter for channel subscription |
| `notify` | `NotifyFn` | — | Connection state notifications |
| `translate` | `TranslateFn` | — | Translation function for notification text |
| `defaultChannel` | `string` | `'websocket'` | Default channel name |
| `heartbeatInterval` | `number` | `20000` | Ping interval (ms) |
| `reconnectDelay` | `number` | `3000` | Delay before reconnect attempt (ms) |
| `autoReconnect` | `boolean` | `true` | Auto-reconnect on disconnect |
| `isServer` | `boolean \| () => boolean` | `false` | Returns safe no-op stub when true |
| `isNativePlatform` | `boolean \| () => boolean` | `false` | Suppresses browser-only notifications |
**WsClient methods:**
| Method | Returns | Description |
|-------------------------------------------|------------------------------|------------------------------------------|
| `connect(force?)` | `Promise<WebSocket \| void>` | Open socket connection |
| `send(event, data?, channel?, progress?)` | `Promise<T>` | Send event, await response |
| `listen(event, channel, callback)` | `() => void` | Listen for event, returns unsubscribe fn |
| `listenOnce(event, channel?)` | `Promise<any>` | Resolve on next occurrence |
| `setAppReady()` | `void` | Unblock gated `send()` calls |
| `resetConnection()` | `void` | Force channels to re-subscribe |
| `destroy()` | `void` | Close and clean up everything |
**Reactive state (Vue refs when using vue/nuxt adapters, plain objects otherwise):**
| Property | Type | Description |
|----------------------------|------------------------|-----------------------------|
| `is_opened` | `ReactiveRef<boolean>` | Socket is open |
| `is_setup` | `ReactiveRef<boolean>` | Default channel established |
| `is_connecting_socket` | `ReactiveRef<boolean>` | Connection in progress |
| `is_after_lost_connection` | `ReactiveRef<boolean>` | Had a connection loss |
## Storage Adapters
```typescript
import { browserStorage, memoryStorage } from '@blax-software/networking'
// Default — uses localStorage (safe for SSR, returns null on errors)
const api1 = createApiClient({ serverUrl: '...', storage: browserStorage })
// In-memory — for SSR, tests, or environments without localStorage
const api2 = createApiClient({ serverUrl: '...', storage: memoryStorage() })
```
## License
MIT

2063
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View File

@ -0,0 +1,73 @@
{
"name": "@blax-software/networking",
"version": "0.1.0",
"description": "Plug-and-play API + WebSocket client. Framework-agnostic core with optional Vue, Nuxt, and React bindings.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./vue": {
"types": "./dist/vue.d.ts",
"import": "./dist/vue.js",
"require": "./dist/vue.cjs"
},
"./nuxt": {
"types": "./dist/nuxt.d.ts",
"import": "./dist/nuxt.js",
"require": "./dist/nuxt.cjs"
},
"./axios": {
"types": "./dist/api-axios.d.ts",
"import": "./dist/api-axios.js",
"require": "./dist/api-axios.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"axios": ">=1.0.0",
"vue": ">=3.3.0"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
},
"vue": {
"optional": true
}
},
"devDependencies": {
"axios": "^1.7.0",
"tsup": "^8.0.0",
"typescript": "^5.5.0",
"vue": "^3.5.0"
},
"keywords": [
"websocket",
"api-client",
"http",
"fetch",
"vue",
"nuxt",
"react",
"realtime"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/blax-software/npm-networking.git"
}
}

38
src/api-axios.ts Normal file
View File

@ -0,0 +1,38 @@
import type { HttpAdapter, HttpResponse, HttpRequestConfig } from './types'
/**
* Creates an HTTP adapter backed by an axios instance.
*
* @example
* ```ts
* import axios from 'axios'
* import { createApiClient } from '@blax-software/networking'
* import { createAxiosAdapter } from '@blax-software/networking/axios'
*
* const api = createApiClient({
* serverUrl: 'https://api.example.com',
* http: createAxiosAdapter(axios.create({ withCredentials: true })),
* })
* ```
*/
export function createAxiosAdapter(axiosInstance: any): HttpAdapter {
return {
async request<T>(config: HttpRequestConfig): Promise<HttpResponse<T>> {
const res = await axiosInstance.request({
method: config.method,
url: config.url,
data: config.data,
headers: config.headers,
params: config.params,
timeout: config.timeout,
withCredentials: config.withCredentials,
})
return {
data: res.data,
status: res.status,
headers: res.headers ?? {},
}
},
}
}

362
src/api.ts Normal file
View File

@ -0,0 +1,362 @@
import type {
ApiClientConfig,
HttpAdapter,
HttpResponse,
HttpRequestConfig,
NotifyFn,
StorageAdapter,
} from './types'
import { browserStorage, memoryStorage } from './types'
// ---------------------------------------------------------------------------
// Built-in fetch adapter
// ---------------------------------------------------------------------------
function serializeParams(params: any, prefix = ''): string {
const parts: string[] = []
for (const key in params) {
if (!Object.prototype.hasOwnProperty.call(params, key)) continue
const value = params[key]
if (value === undefined || value === null) continue
const newKey = prefix
? `${prefix}[${encodeURIComponent(key)}]`
: encodeURIComponent(key)
if (typeof value === 'object' && !Array.isArray(value)) {
parts.push(serializeParams(value, newKey))
} else {
parts.push(`${newKey}=${encodeURIComponent(value)}`)
}
}
return parts.filter(Boolean).join('&')
}
export const fetchAdapter: HttpAdapter = {
async request<T>(config: HttpRequestConfig): Promise<HttpResponse<T>> {
let url = config.url
if (config.params) {
const qs = serializeParams(config.params)
if (qs) url += (url.includes('?') ? '&' : '?') + qs
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(config.headers ?? {}),
}
const init: RequestInit = {
method: config.method,
headers,
credentials: config.withCredentials ? 'include' : 'same-origin',
}
// Timeout via AbortSignal when available
if (config.timeout && typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal) {
init.signal = AbortSignal.timeout(config.timeout)
}
if (config.data !== undefined && config.method !== 'GET') {
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
init.body = config.data
// Let the browser set Content-Type with boundary
delete (init.headers as Record<string, string>)['Content-Type']
} else {
init.body = JSON.stringify(config.data)
}
}
const res = await fetch(url, init)
const contentType = res.headers.get('content-type') ?? ''
let data: any
if (contentType.includes('application/json')) {
data = await res.json()
} else {
data = await res.text()
}
if (!res.ok) {
const error: any = new Error(data?.message ?? `HTTP ${res.status}`)
error.response = { data, status: res.status, headers: {} }
error.status = res.status
throw error
}
const responseHeaders: Record<string, string> = {}
res.headers.forEach((v, k) => { responseHeaders[k] = v })
return { data, status: res.status, headers: responseHeaders }
},
}
// ---------------------------------------------------------------------------
// API Client
// ---------------------------------------------------------------------------
export class ApiClient {
private _config: Required<
Pick<ApiClientConfig, 'timeout' | 'withCredentials' | 'apiPrefix' | 'storageKey' | 'maxRetries'>
> &
ApiClientConfig
private _http: HttpAdapter
private _storage: StorageAdapter
private _notify: NotifyFn | undefined
private _bearerToken: string = ''
/**
* When true, `parseError` throws without showing notifications.
* Useful during app bootstrap to suppress transient errors.
*/
silentErrors = false
constructor(config: ApiClientConfig) {
this._config = {
timeout: 10000,
withCredentials: true,
apiPrefix: 'api/',
storageKey: 'bearerToken',
retryOn503: true,
maxRetries: 2,
...config,
}
this._http = config.http ?? fetchAdapter
this._storage = config.storage ?? (this._isServer() ? memoryStorage() : browserStorage)
this._notify = config.notify
}
// -------------------------------------------------------------------------
// Configuration
// -------------------------------------------------------------------------
/** Update configuration at runtime. */
configure(partial: Partial<ApiClientConfig>): void {
Object.assign(this._config, partial)
if (partial.http) this._http = partial.http
if (partial.storage) this._storage = partial.storage
if (partial.notify !== undefined) this._notify = partial.notify
}
private _isServer(): boolean {
const s = this._config.isServer
return typeof s === 'function' ? s() : (s ?? false)
}
// -------------------------------------------------------------------------
// URL helpers
// -------------------------------------------------------------------------
/** Resolve the current backend base URL (trailing slash guaranteed). */
getBackendUrl(): string {
const getter =
this._isServer() && this._config.ssrServerUrl
? this._config.ssrServerUrl
: this._config.serverUrl
let url = typeof getter === 'function' ? getter() : getter
if (!url) throw new Error('[networking] serverUrl is not configured')
if (!url.endsWith('/')) url += '/'
return url
}
/**
* Build a full URL for a client asset served from a warehouse endpoint.
* Override in subclass or configure a custom path if your backend differs.
*/
clientAsset(path: string): string {
return this.getBackendUrl() + 'warehouse/' + path + '?clientasset=true'
}
/**
* Normalize a URL path:
* - Leading `/` is stripped (treated as absolute relative to backend root).
* - Otherwise, `apiPrefix` is prepended if absent.
*/
cleanseUrl(url: string): string {
if (url.startsWith('/')) return url.substring(1)
if (!url.startsWith(this._config.apiPrefix!)) return this._config.apiPrefix + url
return url
}
// -------------------------------------------------------------------------
// Auth
// -------------------------------------------------------------------------
get bearerToken(): string {
return this._bearerToken
}
setBearer(token: string | null): void {
if (!token) {
this._bearerToken = ''
if (!this._isServer()) {
this._storage.remove(this._config.storageKey!)
}
return
}
this._bearerToken = token
if (!this._isServer()) {
this._storage.set(this._config.storageKey!, token)
}
}
/** Read the bearer token from storage and activate it. Returns the token or null. */
loadBearerFromStorage(): string | null {
if (this._isServer()) return null
const token = this._storage.get(this._config.storageKey!)
if (token) this._bearerToken = token
return token
}
// -------------------------------------------------------------------------
// Internal request plumbing
// -------------------------------------------------------------------------
private _buildHeaders(overrides?: Record<string, string>): Record<string, string> {
const base: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
if (this._bearerToken) {
base['Authorization'] = `Bearer ${this._bearerToken}`
}
const defaults = this._config.defaultHeaders
if (defaults) {
Object.assign(base, typeof defaults === 'function' ? defaults() : defaults)
}
if (overrides) Object.assign(base, overrides)
return base
}
private async _request<T>(config: HttpRequestConfig, attempt = 0): Promise<HttpResponse<T>> {
try {
return await this._http.request<T>(config)
} catch (error: any) {
if (
this._config.retryOn503 &&
error?.status === 503 &&
attempt < this._config.maxRetries
) {
const backoffMs = (attempt + 1) * 1000
await new Promise((r) => setTimeout(r, backoffMs))
return this._request<T>(config, attempt + 1)
}
throw error
}
}
// -------------------------------------------------------------------------
// HTTP verbs
// -------------------------------------------------------------------------
async get<T = any>(url: string, params?: any): Promise<HttpResponse<T>> {
return this._request<T>({
method: 'GET',
url: this.getBackendUrl() + this.cleanseUrl(url),
params,
headers: this._buildHeaders(),
timeout: this._config.timeout,
withCredentials: this._config.withCredentials,
})
}
async post<T = any>(url: string, data?: any, headers?: Record<string, string>): Promise<HttpResponse<T>> {
return this._request<T>({
method: 'POST',
url: this.getBackendUrl() + this.cleanseUrl(url),
data,
headers: this._buildHeaders(headers),
timeout: this._config.timeout,
withCredentials: this._config.withCredentials,
})
}
async put<T = any>(url: string, data?: any): Promise<HttpResponse<T>> {
return this._request<T>({
method: 'PUT',
url: this.getBackendUrl() + this.cleanseUrl(url),
data,
headers: this._buildHeaders(),
timeout: this._config.timeout,
withCredentials: this._config.withCredentials,
})
}
async delete<T = any>(url: string, headers?: Record<string, string>): Promise<HttpResponse<T>> {
return this._request<T>({
method: 'DELETE',
url: this.getBackendUrl() + this.cleanseUrl(url),
headers: this._buildHeaders(headers),
timeout: this._config.timeout,
withCredentials: this._config.withCredentials,
})
}
async patch<T = any>(url: string, data?: any): Promise<HttpResponse<T>> {
return this._request<T>({
method: 'PATCH',
url: this.getBackendUrl() + this.cleanseUrl(url),
data,
headers: this._buildHeaders(),
timeout: this._config.timeout,
withCredentials: this._config.withCredentials,
})
}
/**
* Fetch a CSRF cookie. The default path matches Laravel Sanctum
* but you can pass any path your backend uses.
*/
async csrf(path = '/sanctum/csrf-cookie'): Promise<HttpResponse<any>> {
return this.get(path)
}
// -------------------------------------------------------------------------
// Error / success handlers
// -------------------------------------------------------------------------
/**
* Extract a readable error message, optionally show a notification, and re-throw.
* Designed as a `.catch()` handler: `api.get('x').catch(api.parseError)`
*/
parseError = (e: any): never => {
console.error(e)
const text =
e?.response?.data?.message ??
e?.response?.message ??
e?.message ??
e?.response?.data?.errors ??
'An unknown error occurred'
if (this._notify && !this.silentErrors) {
this._notify({
text: typeof text === 'string' ? text : JSON.stringify(text),
type: 'error',
errors: e?.response?.data?.errors,
timeout: 3000,
})
}
throw e
}
/**
* Show a success notification from a response.
* Usage: `api.post('x').then(api.parseThen)`
*/
parseThen = (res: any, fallback?: string): void => {
const msg = res?.data?.message ?? res?.message ?? fallback ?? 'Success'
if (this._notify) {
this._notify(typeof msg === 'string' ? msg : JSON.stringify(msg))
}
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createApiClient(config: ApiClientConfig): ApiClient {
return new ApiClient(config)
}

21
src/index.ts Normal file
View File

@ -0,0 +1,21 @@
// Core (framework-agnostic)
export { createApiClient, fetchAdapter } from './api'
export type { ApiClient } from './api'
export { createWsClient } from './ws'
export type { WsClient, WsChannel } from './ws'
// Types & adapters
export type {
NotifyFn,
NotifyOptions,
TranslateFn,
StorageAdapter,
HttpAdapter,
HttpResponse,
HttpRequestConfig,
ApiClientConfig,
WsClientConfig,
ReactiveRef,
CreateRefFn,
} from './types'
export { browserStorage, memoryStorage, plainRef } from './types'

103
src/nuxt.ts Normal file
View File

@ -0,0 +1,103 @@
import { ref } from 'vue'
import { createApiClient, type ApiClient } from './api'
import { createWsClient, type WsClient } from './ws'
import type { ApiClientConfig, WsClientConfig, ReactiveRef } from './types'
/**
* Options for `createFromNuxtConfig()`.
*
* Override the runtimeConfig key names if your project uses different names.
* All keys read from `useRuntimeConfig().public`.
*/
export interface NuxtNetworkingOptions {
/** runtimeConfig key for REST API base URL (default: `'SERVER_URL'`) */
serverUrlKey?: string
/** runtimeConfig key for internal SSR URL (default: `'SERVER_URL_INTERNAL'`) */
serverUrlInternalKey?: string
/** runtimeConfig key for WebSocket URL (default: `'WEBS_URL'`) */
wsUrlKey?: string
/** runtimeConfig key for WS protocol — `'wss'` or `'ws'` (default: `'WS_PROTOCOL'`) */
wsProtocolKey?: string
/** Additional ApiClientConfig overrides */
apiConfig?: Partial<ApiClientConfig>
/** Additional WsClientConfig overrides */
wsConfig?: Partial<WsClientConfig>
}
/**
* Create api + ws clients pre-wired for a Nuxt 3 app.
*
* Reads URLs from `useRuntimeConfig().public`, detects SSR via `import.meta.server`,
* and uses Vue `ref()` for WS reactive state.
*
* ```ts
* // plugins/networking.client.ts
* import { createFromNuxtConfig } from '@blax-software/networking/nuxt'
*
* export default defineNuxtPlugin(() => {
* const { api, ws } = createFromNuxtConfig()
* return { provide: { api, ws } }
* })
* ```
*/
export function createFromNuxtConfig(options: NuxtNetworkingOptions = {}): {
api: ApiClient
ws: WsClient
} {
// Nuxt auto-imports — these are available at plugin/composable scope
// @ts-expect-error Nuxt auto-import
const config = useRuntimeConfig()
const serverUrlKey = options.serverUrlKey ?? 'SERVER_URL'
const serverUrlInternalKey = options.serverUrlInternalKey ?? 'SERVER_URL_INTERNAL'
const wsUrlKey = options.wsUrlKey ?? 'WEBS_URL'
const wsProtocolKey = options.wsProtocolKey ?? 'WS_PROTOCOL'
const pub = config.public ?? config
const serverUrl: string = pub[serverUrlKey] ?? ''
const serverUrlInternal: string = pub[serverUrlInternalKey] ?? ''
const wsUrl: string = pub[wsUrlKey] ?? ''
const wsProtocol: string = pub[wsProtocolKey] ?? 'wss'
// @ts-expect-error Nuxt/Vite global
const isServer: boolean = import.meta.server ?? false
// --- API Client ---
const api = createApiClient({
serverUrl,
ssrServerUrl: serverUrlInternal || undefined,
isServer: () => isServer,
defaultHeaders: () => {
if (!isServer) return {}
try {
// @ts-expect-error Nuxt auto-import
return useRequestHeaders(['cookie', 'x-forwarded-for', 'x-real-ip']) ?? {}
} catch {
return {}
}
},
...options.apiConfig,
})
// --- WS Client ---
// Nuxt always has Vue — use ref directly for reactive state
const vueRef = <T>(initial: T): ReactiveRef<T> => ref(initial) as ReactiveRef<T>
const ws = createWsClient(
{
url: `${wsProtocol === 'wss' ? 'wss' : 'ws'}://${wsUrl}/app/ws`,
isServer: () => isServer,
...options.wsConfig,
},
vueRef,
)
return { api, ws }
}
// Re-export for convenience
export { createApiClient } from './api'
export { createWsClient } from './ws'
export type { ApiClient } from './api'
export type { WsClient, WsChannel } from './ws'

199
src/types.ts Normal file
View File

@ -0,0 +1,199 @@
// ---------------------------------------------------------------------------
// Notification adapter
// ---------------------------------------------------------------------------
export interface NotifyOptions {
id?: string | number
text: string
type: 'success' | 'error' | 'warning' | 'info'
timeout?: number
errors?: any
}
export type NotifyFn = (opts: NotifyOptions | string, type?: NotifyOptions['type']) => void
export type TranslateFn = (key: string) => string | null
// ---------------------------------------------------------------------------
// Storage adapter (replaces direct localStorage usage)
// ---------------------------------------------------------------------------
export interface StorageAdapter {
get(key: string): string | null
set(key: string, value: string): void
remove(key: string): void
}
/** Default browser localStorage adapter. Safe for SSR (returns null on failure). */
export const browserStorage: StorageAdapter = {
get: (key) => {
try { return localStorage.getItem(key) } catch { return null }
},
set: (key, value) => {
try { localStorage.setItem(key, value) } catch {}
},
remove: (key) => {
try { localStorage.removeItem(key) } catch {}
},
}
/** In-memory storage. Use for SSR or test environments. */
export const memoryStorage = (): StorageAdapter => {
const store = new Map<string, string>()
return {
get: (key) => store.get(key) ?? null,
set: (key, value) => { store.set(key, value) },
remove: (key) => { store.delete(key) },
}
}
// ---------------------------------------------------------------------------
// HTTP adapter interface (fetch or axios or anything else)
// ---------------------------------------------------------------------------
export interface HttpResponse<T = any> {
data: T
status: number
headers: Record<string, string>
}
export interface HttpAdapter {
request<T = any>(config: HttpRequestConfig): Promise<HttpResponse<T>>
}
export interface HttpRequestConfig {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
url: string
data?: any
headers?: Record<string, string>
params?: Record<string, any>
timeout?: number
withCredentials?: boolean
}
// ---------------------------------------------------------------------------
// API client config
// ---------------------------------------------------------------------------
export interface ApiClientConfig {
/** Base URL for HTTP requests. String or getter for dynamic resolution. */
serverUrl: string | (() => string)
/**
* Optional separate URL for server-side requests (e.g. internal Docker network).
* Only used when `isServer` returns true.
*/
ssrServerUrl?: string | (() => string)
/** HTTP adapter. Defaults to built-in fetch adapter. */
http?: HttpAdapter
/** Notification callback for errors and success messages. */
notify?: NotifyFn
/** Token persistence adapter. Defaults to browserStorage. */
storage?: StorageAdapter
/** Key under which the bearer token is stored. Default: `'bearerToken'` */
storageKey?: string
/** Default request timeout in ms. Default: `10000` */
timeout?: number
/** Whether to include credentials (cookies). Default: `true` */
withCredentials?: boolean
/** Auto-retry on HTTP 503. Default: `true` */
retryOn503?: boolean
/** Max retry attempts on 503. Default: `2` */
maxRetries?: number
/**
* Prefix prepended to relative URL paths.
* E.g. `'api/'` turns `'users'` into `'api/users'`.
* Default: `'api/'`
*/
apiPrefix?: string
/** Extra default headers merged into every request. */
defaultHeaders?: Record<string, string> | (() => Record<string, string>)
/**
* Whether we are running on the server (SSR).
* Affects URL resolution and storage.
* Default: `false`
*/
isServer?: boolean | (() => boolean)
}
// ---------------------------------------------------------------------------
// WebSocket client config
// ---------------------------------------------------------------------------
export interface WsClientConfig {
/** Full WebSocket URL (e.g. `'wss://example.com/app/ws'`). String or getter. */
url: string | (() => string)
/** Called on each channel establishment to get the current auth token. */
getAuthToken?: () => string | null | undefined
/** Notification callback for connection state changes. */
notify?: NotifyFn
/** Translation function for connection state messages. */
translate?: TranslateFn
/** Default channel name. Default: `'websocket'` */
defaultChannel?: string
/** Heartbeat (ping) interval in ms. Default: `20000` */
heartbeatInterval?: number
/** Delay in ms before attempting reconnection. Default: `3000` */
reconnectDelay?: number
/** Minimum ms between reconnect attempts. Default: `3000` */
reconnectThrottle?: number
/** Auto-reconnect on connection loss. Default: `true` */
autoReconnect?: boolean
/**
* Show connection lost/restored notifications.
* Set to `false` on native mobile apps where a toast overlay may be unwanted.
* Default: `true`
*/
showConnectionNotifications?: boolean
/**
* Native platform check (e.g. Capacitor).
* When true, suppresses connection-state notifications.
*/
isNativePlatform?: boolean | (() => boolean)
/** Whether running on the server (SSR). Returns an inert stub when true. */
isServer?: boolean | (() => boolean)
/** Fired when connection state changes. */
onConnectionStateChange?: (state: 'connecting' | 'connected' | 'disconnected' | 'reconnecting') => void
}
// ---------------------------------------------------------------------------
// Reactive ref abstraction (works with Vue, React state, or plain objects)
// ---------------------------------------------------------------------------
/** Minimal reactive value container. Compatible with Vue `ref()` or a plain object. */
export interface ReactiveRef<T> {
value: T
}
/**
* Factory function for creating reactive refs.
* - Vue users pass `ref` from `'vue'`
* - React users can wrap `useState`
* - Others use `plainRef` (default)
*/
export type CreateRefFn = <T>(initial: T) => ReactiveRef<T>
/** Non-reactive ref — plain object with a `.value` property. */
export const plainRef = <T>(initial: T): ReactiveRef<T> => ({ value: initial })

85
src/vue.ts Normal file
View File

@ -0,0 +1,85 @@
import { ref, onUnmounted } from 'vue'
import { createApiClient, type ApiClient } from './api'
import { createWsClient, type WsClient } from './ws'
import type {
ApiClientConfig,
WsClientConfig,
CreateRefFn,
ReactiveRef,
} from './types'
// ---------------------------------------------------------------------------
// Vue ref adapter
// ---------------------------------------------------------------------------
const vueRef: CreateRefFn = <T>(initial: T): ReactiveRef<T> => ref(initial) as ReactiveRef<T>
// ---------------------------------------------------------------------------
// Composables
// ---------------------------------------------------------------------------
/**
* Create (or return) an ApiClient.
* Convenience wrapper you can also call `createApiClient()` directly.
*/
export function useApiClient(config: ApiClientConfig): ApiClient {
return createApiClient(config)
}
/**
* Create a WsClient with Vue `ref()` for reactive state.
* `ws.is_setup`, `ws.is_opened`, etc. are Vue refs.
*/
export function useWsClient(config: WsClientConfig): WsClient {
return createWsClient(config, vueRef)
}
/**
* Listen for a WS event with automatic cleanup on component unmount.
*
* ```ts
* useWsListener(ws, 'chat.message', null, (data) => { ... })
* ```
*/
export function useWsListener(
ws: WsClient,
event: string,
channel: string | null | undefined,
callback: (data: any) => void,
): () => void {
const off = ws.listen(event, channel, callback)
onUnmounted(off)
return off
}
/**
* Resolve once when a WS event fires. Cleans up automatically if the component unmounts first.
*/
export function useWsListenOnce(
ws: WsClient,
event: string,
channel?: string | null,
): Promise<any> {
let off: (() => void) | null = null
const promise = new Promise<any>((resolve) => {
off = ws.listen(event, channel, (data) => {
off?.()
off = null
resolve(data)
})
})
onUnmounted(() => {
off?.()
})
return promise
}
// Re-export everything from core for convenience
export { createApiClient } from './api'
export { createWsClient } from './ws'
export { vueRef }
export type { ApiClient } from './api'
export type { WsClient, WsChannel } from './ws'

629
src/ws.ts Normal file
View File

@ -0,0 +1,629 @@
import type {
WsClientConfig,
NotifyFn,
TranslateFn,
ReactiveRef,
CreateRefFn,
} from './types'
import { plainRef } from './types'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface WsSocket extends WebSocket {
socket_id?: string
}
export interface WsChannel {
name: string
is_established: boolean
establish(): Promise<WsChannel>
send(event: string, data?: any): Promise<any>
unsubscribe(): Promise<boolean>
}
export interface WsClient {
socket: WsSocket | null
channels: WsChannel[]
is_opened: ReactiveRef<boolean>
is_setup: ReactiveRef<boolean>
is_connecting_socket: ReactiveRef<boolean>
is_after_lost_connection: ReactiveRef<boolean>
heartbeat: ReturnType<typeof setInterval> | null
last_reconnect_try: number
send_queue: any[]
connect(force_reset?: boolean): Promise<WsSocket | void>
ensureConnected(): Promise<void>
channel(channel_name?: string | null): Promise<WsChannel | undefined>
send<T = any>(
event: string,
data?: object,
channel_name?: string | null,
progress?: (data: any) => void,
): Promise<T>
unsubscribe(channel_name?: string | null): Promise<boolean>
/**
* Listen for a WS event. Returns an unsubscribe function.
* Works with any framework's cleanup (React useEffect, Vue onUnmounted, etc.)
*/
listen(event: string, channel_name: string | null | undefined, callback: (data: any) => void): () => void
/** Resolve once when the given event fires. */
listenOnce(event: string, channel_name?: string | null): Promise<any>
/** Signal that app initialization is complete. Unblocks gated send() calls. */
setAppReady(): void
/** Force channels to re-subscribe with the current auth token on next send(). */
resetConnection(): void
/** Update configuration at runtime. */
configure(partial: Partial<WsClientConfig>): void
/** Close the connection, clear intervals, and reset all state. */
destroy(): void
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const _isProtocolEvent = (event: string): boolean =>
/[.:](?:subscribe|unsubscribe|ping|pong)$/.test(event)
// ---------------------------------------------------------------------------
// SSR stub — safe no-op returned when isServer is true
// ---------------------------------------------------------------------------
function createSsrStub(createRef: CreateRefFn): WsClient {
return {
socket: null,
channels: [],
is_opened: createRef(false),
is_setup: createRef(false),
is_connecting_socket: createRef(false),
is_after_lost_connection: createRef(false),
heartbeat: null,
last_reconnect_try: 0,
send_queue: [],
connect: () => Promise.resolve(),
ensureConnected: () => Promise.resolve(),
channel: () => Promise.resolve(undefined),
send: () => Promise.resolve(null as any),
unsubscribe: () => Promise.resolve(true),
listen: () => () => {},
listenOnce: () => Promise.resolve(null),
setAppReady: () => {},
resetConnection: () => {},
configure: () => {},
destroy: () => {},
}
}
// ---------------------------------------------------------------------------
// Channel
// ---------------------------------------------------------------------------
class WebsocketChannel implements WsChannel {
name: string
is_established = false
_establishPromise: Promise<WsChannel> | null = null
private _ws: WsClientImpl
constructor(name: string, ws: WsClientImpl) {
this.name = name
this._ws = ws
ws.channels.push(this)
}
async establish(): Promise<WsChannel> {
if (this.is_established && this._ws.is_setup.value) return this
this._establishPromise ??= this._doEstablish()
return this._establishPromise
}
private async _doEstablish(): Promise<WsChannel> {
try {
await this._ws.ensureConnected()
const authtoken = this._ws._getAuthToken()
await this._ws.send(
'websocket.subscribe',
{ channel: this.name, authtoken: authtoken ?? undefined },
null,
)
this.is_established = true
this._ws.is_setup.value = true
return this
} catch (error) {
this.is_established = false
this._establishPromise = null
throw error
}
}
async send(event: string, data: any = {}): Promise<any> {
if (!_isProtocolEvent(event) && !this.is_established) await this.establish()
return this._ws.send(event, data, _isProtocolEvent(event) ? null : this.name)
}
async unsubscribe(): Promise<boolean> {
if (this.is_established) {
await this._ws
.send('websocket.unsubscribe', { channel: this.name }, this.name)
.catch(() => {})
}
this._ws.channels = this._ws.channels.filter((c) => c !== this)
return true
}
}
// ---------------------------------------------------------------------------
// Main WS client
// ---------------------------------------------------------------------------
class WsClientImpl extends EventTarget implements WsClient {
private _config: WsClientConfig
private _notify: NotifyFn | undefined
private _translate: TranslateFn | undefined
socket: WsSocket | null = null
channels: WsChannel[] = []
is_opened: ReactiveRef<boolean>
is_setup: ReactiveRef<boolean>
is_connecting_socket: ReactiveRef<boolean>
is_after_lost_connection: ReactiveRef<boolean>
heartbeat: ReturnType<typeof setInterval> | null = null
last_reconnect_try = 0
send_queue: any[] = []
// App-readiness gate
_appReady = false
private _appReadyResolve: (() => void) | null = null
private _appReadyPromise: Promise<void>
// Connection-ready promise
private _connectedResolve: (() => void) | null = null
private _connectedPromise: Promise<void> | null = null
// Connect promise coalescing
private _connectPromise: Promise<WsSocket | void> | null = null
constructor(config: WsClientConfig, createRef: CreateRefFn) {
super()
this._config = {
defaultChannel: 'websocket',
heartbeatInterval: 20_000,
reconnectDelay: 3000,
reconnectThrottle: 3000,
autoReconnect: true,
showConnectionNotifications: true,
...config,
}
this._notify = config.notify
this._translate = config.translate
this.is_opened = createRef(false)
this.is_setup = createRef(false)
this.is_connecting_socket = createRef(false)
this.is_after_lost_connection = createRef(false)
this._appReadyPromise = new Promise<void>((r) => {
this._appReadyResolve = r
})
}
// -------------------------------------------------------------------------
// Configuration
// -------------------------------------------------------------------------
configure(partial: Partial<WsClientConfig>): void {
Object.assign(this._config, partial)
if (partial.notify !== undefined) this._notify = partial.notify
if (partial.translate !== undefined) this._translate = partial.translate
}
/** @internal — called by channels to obtain the current auth token */
_getAuthToken(): string | null | undefined {
return this._config.getAuthToken?.()
}
private _isNativePlatform(): boolean {
const v = this._config.isNativePlatform
return typeof v === 'function' ? v() : (v ?? false)
}
private _getUrl(): string {
const u = this._config.url
return typeof u === 'function' ? u() : u
}
private _t(key: string, fallback: string): string {
if (this._translate) {
const result = this._translate(key)
if (result) return result
}
return fallback
}
private _shouldNotify(): boolean {
return (
this._config.showConnectionNotifications !== false &&
!this._isNativePlatform() &&
!!this._notify
)
}
// -------------------------------------------------------------------------
// Connection lifecycle
// -------------------------------------------------------------------------
ensureConnected(): Promise<void> {
if (this.socket?.socket_id && this.is_opened.value) return Promise.resolve()
if (!this._connectedPromise) {
this._connectedPromise = new Promise<void>((r) => {
this._connectedResolve = r
})
this.connect().catch(() => {})
}
return this._connectedPromise
}
setAppReady(): void {
this._appReady = true
this._appReadyResolve?.()
}
resetConnection(): void {
for (const ch of this.channels) {
const c = ch as WebsocketChannel
c.is_established = false
c._establishPromise = null
}
this.is_setup.value = false
}
async connect(force_reset = false): Promise<WsSocket | void> {
if (force_reset && this.socket) {
try {
this.socket.close()
} catch {}
this.socket = null
this._connectPromise = null
}
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) return this.socket
if (this._connectPromise) return this._connectPromise
const throttle = this._config.reconnectThrottle ?? 3000
if (this.last_reconnect_try && Date.now() - this.last_reconnect_try < throttle) {
console.log('[ws] Reconnect too fast, skipping')
return
}
this.last_reconnect_try = Date.now()
this._connectPromise = this._doConnect(force_reset)
return this._connectPromise
}
private _doConnect(force_reset: boolean): Promise<WsSocket | void> {
const hbInterval = this._config.heartbeatInterval ?? 20_000
if (force_reset || !this.heartbeat) {
if (this.heartbeat) clearInterval(this.heartbeat)
this.heartbeat = setInterval(() => {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send('{"event":"websocket.ping","data":{}}')
}
}, hbInterval)
}
if (force_reset) this.channels = []
const url = this._getUrl()
this.socket = new WebSocket(url) as WsSocket
this.is_connecting_socket.value = true
this._config.onConnectionStateChange?.('connecting')
return new Promise<WsSocket | void>((resolve, reject) => {
const socket = this.socket!
socket.addEventListener('error', () => {
this.is_connecting_socket.value = false
this._connectPromise = null
reject(new Error('WebSocket error'))
})
socket.addEventListener('close', () => {
this.channels = []
this.is_connecting_socket.value = false
this.is_opened.value = false
this.is_setup.value = false
this.socket = null
this._connectPromise = null
this._connectedPromise = null
this._connectedResolve = null
this.is_after_lost_connection.value = true
if (this._shouldNotify()) {
const text = this._t('websocket.connectionlost', 'Connection lost. Reconnecting…')
this._notify!({ id: 'websocket-connection-state', type: 'info', text, timeout: 50_000 })
}
this._config.onConnectionStateChange?.('disconnected')
if (this._appReady && this._config.autoReconnect !== false) {
const delay = this._config.reconnectDelay ?? 3000
this._config.onConnectionStateChange?.('reconnecting')
setTimeout(() => this.connect().catch(() => {}), delay)
}
reject(new Error('Socket closed'))
})
socket.addEventListener('open', () => {
if (this.is_after_lost_connection.value && this._shouldNotify()) {
const text = this._t('websocket.connectionrestored', 'Connection restored')
this._notify!({ id: 'websocket-connection-state', type: 'success', text, timeout: 1000 })
}
this.is_opened.value = true
this._config.onConnectionStateChange?.('connected')
// Warmup ping
socket.send('{"event":"websocket.ping","data":{}}')
})
socket.addEventListener('message', (raw) => {
const msg = JSON.parse(raw.data)
if (msg?.event === 'websocket.connection_established') {
const data = JSON.parse(msg.data)
if (data?.socket_id && this.socket) {
this.socket.socket_id = data.socket_id
this.is_connecting_socket.value = false
resolve(this.socket)
this._connectedResolve?.()
// Proactively establish the default channel
this.channel()
this._workSendQueue()
}
return
}
// Parse stringified data payloads
if (msg?.data && typeof msg.data === 'string') {
try {
msg.data = JSON.parse(msg.data)
} catch {}
}
this.dispatchEvent(
new CustomEvent(msg.event, {
detail: { event: msg.event, data: msg.data, channel: msg.channel },
}),
)
})
})
}
// -------------------------------------------------------------------------
// Channel management
// -------------------------------------------------------------------------
async channel(channel_name: string | null = null): Promise<WsChannel | undefined> {
channel_name ??= this._config.defaultChannel ?? 'websocket'
const existing = this.channels.find((c) => c.name === channel_name)
return (existing ?? new WebsocketChannel(channel_name, this)).establish()
}
private async _workSendQueue(): Promise<void> {
if (!this.send_queue.length) return
const queue = this.send_queue
this.send_queue = []
for (const payload of queue) {
await this.channel(payload.channel)
this.socket?.send(JSON.stringify(payload))
}
}
// -------------------------------------------------------------------------
// Send / receive
// -------------------------------------------------------------------------
async send<T = any>(
event: string,
data: object = {},
channel_name: string | null = null,
progress?: (data: any) => void,
_retryOnSubscriptionLost = true,
): Promise<T> {
// Gate non-protocol events until app signals readiness
if (!this._appReady && !_isProtocolEvent(event)) {
await this._appReadyPromise
}
channel_name ??= this._config.defaultChannel ?? 'websocket'
if (!this.socket) await this.connect()
// Build unique event suffix so the server response can be matched to this call
let sendingevent: string
if (event === 'websocket.subscribe') {
sendingevent = 'websocket.subscribe'
channel_name = null
} else {
sendingevent = event + '[' + Math.random().toString(36).substring(7) + ']'
}
const payload = { event: sendingevent, data, channel: channel_name }
// Ensure the target channel is subscribed
if (channel_name && !_isProtocolEvent(event)) {
await this.channel(channel_name)
}
// Send or queue
if (this.is_opened.value && this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload))
} else {
this.send_queue.push(payload)
}
await this.connect()
if (!this.socket) throw new Error('Socket not connected')
const startTime =
typeof performance !== 'undefined' ? performance.now() : Date.now()
return new Promise<T>((resolve, reject) => {
const cleanup = () => {
this.removeEventListener(sendingevent + ':progress', handler)
this.removeEventListener(sendingevent + ':error', handler)
this.removeEventListener(sendingevent + ':response', handler)
}
const handler = (m: any) => {
const msg = m.detail
const duration = Math.round(
(typeof performance !== 'undefined' ? performance.now() : Date.now()) -
startTime,
)
// Success
if (
(event === 'websocket.subscribe' &&
msg?.data?.channel === channel_name) ||
msg?.event?.includes(sendingevent + ':response')
) {
cleanup()
console.log(`[ws] ${sendingevent} ${duration}ms`)
resolve(msg.data)
return
}
// Error / timeout
if (
msg?.event?.includes(sendingevent + ':error') ||
msg?.event?.includes(sendingevent + ':timeout')
) {
cleanup()
console.log(`[ws] ${sendingevent} failed ${duration}ms`)
reject(msg.data)
return
}
// Progress
if (progress && msg?.event?.includes(sendingevent + ':progress')) {
progress(msg.data)
}
}
this.addEventListener(sendingevent + ':progress', handler)
this.addEventListener(sendingevent + ':error', handler)
this.addEventListener(sendingevent + ':response', handler)
}).catch((error: any) => {
// If the server dropped our subscription, re-establish and retry once
if (
_retryOnSubscriptionLost &&
error?.message === 'Subscription not established'
) {
const ch = this.channels.find(
(c) => c.name === channel_name,
) as WebsocketChannel | undefined
if (ch) {
ch.is_established = false
ch._establishPromise = null
}
console.log('[ws] Re-establishing channel after subscription loss')
return this.send<T>(event, data, channel_name, progress, false)
}
throw error
}) as Promise<T>
}
// -------------------------------------------------------------------------
// Event listeners (framework-agnostic — return cleanup function)
// -------------------------------------------------------------------------
listen(
event: string,
channel_name: string | null | undefined,
callback: (data: any) => void,
): () => void {
channel_name ??= this._config.defaultChannel ?? 'websocket'
const handler = (m: any) => {
if (m.detail.channel === channel_name) callback(m.detail.data)
}
this.addEventListener(event, handler)
return () => this.removeEventListener(event, handler)
}
listenOnce(event: string, channel_name?: string | null): Promise<any> {
channel_name ??= this._config.defaultChannel ?? 'websocket'
return new Promise((resolve) => {
const handler = (m: any) => {
if (m.detail.channel === channel_name) {
resolve(m.detail.data)
this.removeEventListener(event, handler)
}
}
this.addEventListener(event, handler)
})
}
async unsubscribe(channel_name?: string | null): Promise<boolean> {
channel_name ??= this._config.defaultChannel ?? 'websocket'
const channel = this.channels.find((c) => c.name === channel_name)
if (channel) await channel.unsubscribe()
this.channels = this.channels.filter((c) => c.name !== channel_name)
return true
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
destroy(): void {
if (this.heartbeat) clearInterval(this.heartbeat)
this.heartbeat = null
if (this.socket) {
try {
this.socket.close()
} catch {}
this.socket = null
}
this.channels = []
this.send_queue = []
this.is_opened.value = false
this.is_setup.value = false
this.is_connecting_socket.value = false
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Create a WebSocket client.
*
* @param config - Connection and behavior configuration.
* @param createRef - Reactive ref factory. Pass `ref` from Vue for reactive state,
* or omit for plain `{ value }` objects.
*/
export function createWsClient(
config: WsClientConfig,
createRef: CreateRefFn = plainRef,
): WsClient {
const isServer =
typeof config.isServer === 'function'
? config.isServer()
: (config.isServer ?? false)
if (isServer) return createSsrStub(createRef)
return new WsClientImpl(config, createRef)
}

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

16
tsup.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: {
index: 'src/index.ts',
vue: 'src/vue.ts',
nuxt: 'src/nuxt.ts',
'api-axios': 'src/api-axios.ts',
},
format: ['esm', 'cjs'],
dts: true,
splitting: true,
clean: true,
treeshake: true,
external: ['vue', 'axios'],
})