Init
This commit is contained in:
commit
1c3c618c27
|
|
@ -0,0 +1,3 @@
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
*.tgz
|
||||||
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ?? {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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 })
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue