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