From afc205d65ba364ea2e94f21bed75df5caf05d445 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 16 Apr 2026 08:36:18 +0200 Subject: [PATCH] chore: include dist/ in repo for GitHub-based installs --- .gitignore | 1 - dist/api-axios.cjs | 25 ++ dist/api-axios.d.cts | 20 + dist/api-axios.d.ts | 20 + dist/api-axios.js | 23 ++ dist/chunk-NJGSOYSN.cjs | 769 ++++++++++++++++++++++++++++++++++++++ dist/chunk-TGWHEE6S.js | 762 +++++++++++++++++++++++++++++++++++++ dist/index.cjs | 30 ++ dist/index.d.cts | 112 ++++++ dist/index.d.ts | 112 ++++++ dist/index.js | 1 + dist/nuxt.cjs | 65 ++++ dist/nuxt.d.cts | 54 +++ dist/nuxt.d.ts | 54 +++ dist/nuxt.js | 56 +++ dist/types-C4-WlXk8.d.cts | 126 +++++++ dist/types-C4-WlXk8.d.ts | 126 +++++++ dist/vue.cjs | 70 ++++ dist/vue.d.cts | 59 +++ dist/vue.d.ts | 59 +++ dist/vue.js | 56 +++ 21 files changed, 2599 insertions(+), 1 deletion(-) create mode 100644 dist/api-axios.cjs create mode 100644 dist/api-axios.d.cts create mode 100644 dist/api-axios.d.ts create mode 100644 dist/api-axios.js create mode 100644 dist/chunk-NJGSOYSN.cjs create mode 100644 dist/chunk-TGWHEE6S.js create mode 100644 dist/index.cjs create mode 100644 dist/index.d.cts create mode 100644 dist/index.d.ts create mode 100644 dist/index.js create mode 100644 dist/nuxt.cjs create mode 100644 dist/nuxt.d.cts create mode 100644 dist/nuxt.d.ts create mode 100644 dist/nuxt.js create mode 100644 dist/types-C4-WlXk8.d.cts create mode 100644 dist/types-C4-WlXk8.d.ts create mode 100644 dist/vue.cjs create mode 100644 dist/vue.d.cts create mode 100644 dist/vue.d.ts create mode 100644 dist/vue.js diff --git a/.gitignore b/.gitignore index e728995..2d98f97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -dist/ node_modules/ *.tgz diff --git a/dist/api-axios.cjs b/dist/api-axios.cjs new file mode 100644 index 0000000..0f49207 --- /dev/null +++ b/dist/api-axios.cjs @@ -0,0 +1,25 @@ +'use strict'; + +// src/api-axios.ts +function createAxiosAdapter(axiosInstance) { + return { + async request(config) { + 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 ?? {} + }; + } + }; +} + +exports.createAxiosAdapter = createAxiosAdapter; diff --git a/dist/api-axios.d.cts b/dist/api-axios.d.cts new file mode 100644 index 0000000..e05b552 --- /dev/null +++ b/dist/api-axios.d.cts @@ -0,0 +1,20 @@ +import { H as HttpAdapter } from './types-C4-WlXk8.cjs'; + +/** + * 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 })), + * }) + * ``` + */ +declare function createAxiosAdapter(axiosInstance: any): HttpAdapter; + +export { createAxiosAdapter }; diff --git a/dist/api-axios.d.ts b/dist/api-axios.d.ts new file mode 100644 index 0000000..82e949d --- /dev/null +++ b/dist/api-axios.d.ts @@ -0,0 +1,20 @@ +import { H as HttpAdapter } from './types-C4-WlXk8.js'; + +/** + * 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 })), + * }) + * ``` + */ +declare function createAxiosAdapter(axiosInstance: any): HttpAdapter; + +export { createAxiosAdapter }; diff --git a/dist/api-axios.js b/dist/api-axios.js new file mode 100644 index 0000000..f8cac8c --- /dev/null +++ b/dist/api-axios.js @@ -0,0 +1,23 @@ +// src/api-axios.ts +function createAxiosAdapter(axiosInstance) { + return { + async request(config) { + 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 ?? {} + }; + } + }; +} + +export { createAxiosAdapter }; diff --git a/dist/chunk-NJGSOYSN.cjs b/dist/chunk-NJGSOYSN.cjs new file mode 100644 index 0000000..b2c801d --- /dev/null +++ b/dist/chunk-NJGSOYSN.cjs @@ -0,0 +1,769 @@ +'use strict'; + +// src/types.ts +var browserStorage = { + 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 { + } + } +}; +var memoryStorage = () => { + const store = /* @__PURE__ */ new Map(); + return { + get: (key) => store.get(key) ?? null, + set: (key, value) => { + store.set(key, value); + }, + remove: (key) => { + store.delete(key); + } + }; +}; +var plainRef = (initial) => ({ value: initial }); + +// src/api.ts +function serializeParams(params, prefix = "") { + const parts = []; + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) continue; + const value = params[key]; + if (value === void 0 || 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("&"); +} +var fetchAdapter = { + async request(config) { + let url = config.url; + if (config.params) { + const qs = serializeParams(config.params); + if (qs) url += (url.includes("?") ? "&" : "?") + qs; + } + const headers = { + "Content-Type": "application/json", + "Accept": "application/json", + ...config.headers ?? {} + }; + const init = { + method: config.method, + headers, + credentials: config.withCredentials ? "include" : "same-origin" + }; + if (config.timeout && typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) { + init.signal = AbortSignal.timeout(config.timeout); + } + if (config.data !== void 0 && config.method !== "GET") { + if (typeof FormData !== "undefined" && config.data instanceof FormData) { + init.body = config.data; + delete init.headers["Content-Type"]; + } else { + init.body = JSON.stringify(config.data); + } + } + const res = await fetch(url, init); + const contentType = res.headers.get("content-type") ?? ""; + let data; + if (contentType.includes("application/json")) { + data = await res.json(); + } else { + data = await res.text(); + } + if (!res.ok) { + const error = new Error(data?.message ?? `HTTP ${res.status}`); + error.response = { data, status: res.status, headers: {} }; + error.status = res.status; + throw error; + } + const responseHeaders = {}; + res.headers.forEach((v, k) => { + responseHeaders[k] = v; + }); + return { data, status: res.status, headers: responseHeaders }; + } +}; +var ApiClient = class { + _config; + _http; + _storage; + _notify; + _bearerToken = ""; + /** + * When true, `parseError` throws without showing notifications. + * Useful during app bootstrap to suppress transient errors. + */ + silentErrors = false; + constructor(config) { + this._config = { + timeout: 1e4, + 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) { + Object.assign(this._config, partial); + if (partial.http) this._http = partial.http; + if (partial.storage) this._storage = partial.storage; + if (partial.notify !== void 0) this._notify = partial.notify; + } + _isServer() { + const s = this._config.isServer; + return typeof s === "function" ? s() : s ?? false; + } + // ------------------------------------------------------------------------- + // URL helpers + // ------------------------------------------------------------------------- + /** Resolve the current backend base URL (trailing slash guaranteed). */ + getBackendUrl() { + 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) { + 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) { + if (url.startsWith("/")) return url.substring(1); + if (!url.startsWith(this._config.apiPrefix)) return this._config.apiPrefix + url; + return url; + } + // ------------------------------------------------------------------------- + // Auth + // ------------------------------------------------------------------------- + get bearerToken() { + return this._bearerToken; + } + setBearer(token) { + 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() { + if (this._isServer()) return null; + const token = this._storage.get(this._config.storageKey); + if (token) this._bearerToken = token; + return token; + } + // ------------------------------------------------------------------------- + // Internal request plumbing + // ------------------------------------------------------------------------- + _buildHeaders(overrides) { + const base = { + "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; + } + async _request(config, attempt = 0) { + try { + return await this._http.request(config); + } catch (error) { + if (this._config.retryOn503 && error?.status === 503 && attempt < this._config.maxRetries) { + const backoffMs = (attempt + 1) * 1e3; + await new Promise((r) => setTimeout(r, backoffMs)); + return this._request(config, attempt + 1); + } + throw error; + } + } + // ------------------------------------------------------------------------- + // HTTP verbs + // ------------------------------------------------------------------------- + async get(url, params) { + return this._request({ + method: "GET", + url: this.getBackendUrl() + this.cleanseUrl(url), + params, + headers: this._buildHeaders(), + timeout: this._config.timeout, + withCredentials: this._config.withCredentials + }); + } + async post(url, data, headers) { + return this._request({ + method: "POST", + url: this.getBackendUrl() + this.cleanseUrl(url), + data, + headers: this._buildHeaders(headers), + timeout: this._config.timeout, + withCredentials: this._config.withCredentials + }); + } + async put(url, data) { + return this._request({ + method: "PUT", + url: this.getBackendUrl() + this.cleanseUrl(url), + data, + headers: this._buildHeaders(), + timeout: this._config.timeout, + withCredentials: this._config.withCredentials + }); + } + async delete(url, headers) { + return this._request({ + method: "DELETE", + url: this.getBackendUrl() + this.cleanseUrl(url), + headers: this._buildHeaders(headers), + timeout: this._config.timeout, + withCredentials: this._config.withCredentials + }); + } + async patch(url, data) { + return this._request({ + 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") { + 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) => { + 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: 3e3 + }); + } + throw e; + }; + /** + * Show a success notification from a response. + * Usage: `api.post('x').then(api.parseThen)` + */ + parseThen = (res, fallback) => { + const msg = res?.data?.message ?? res?.message ?? fallback ?? "Success"; + if (this._notify) { + this._notify(typeof msg === "string" ? msg : JSON.stringify(msg)); + } + }; +}; +function createApiClient(config) { + return new ApiClient(config); +} + +// src/ws.ts +var _isProtocolEvent = (event) => /[.:](?:subscribe|unsubscribe|ping|pong)$/.test(event); +function createSsrStub(createRef) { + 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(void 0), + send: () => Promise.resolve(null), + unsubscribe: () => Promise.resolve(true), + listen: () => () => { + }, + listenOnce: () => Promise.resolve(null), + setAppReady: () => { + }, + resetConnection: () => { + }, + configure: () => { + }, + destroy: () => { + } + }; +} +var WebsocketChannel = class { + name; + is_established = false; + _establishPromise = null; + _ws; + constructor(name, ws) { + this.name = name; + this._ws = ws; + ws.channels.push(this); + } + async establish() { + if (this.is_established && this._ws.is_setup.value) return this; + this._establishPromise ??= this._doEstablish(); + return this._establishPromise; + } + async _doEstablish() { + try { + await this._ws.ensureConnected(); + const authtoken = this._ws._getAuthToken(); + await this._ws.send( + "websocket.subscribe", + { channel: this.name, authtoken: authtoken ?? void 0 }, + 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, data = {}) { + if (!_isProtocolEvent(event) && !this.is_established) await this.establish(); + return this._ws.send(event, data, _isProtocolEvent(event) ? null : this.name); + } + async unsubscribe() { + 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; + } +}; +var WsClientImpl = class extends EventTarget { + _config; + _notify; + _translate; + socket = null; + channels = []; + is_opened; + is_setup; + is_connecting_socket; + is_after_lost_connection; + heartbeat = null; + last_reconnect_try = 0; + send_queue = []; + // App-readiness gate + _appReady = false; + _appReadyResolve = null; + _appReadyPromise; + // Connection-ready promise + _connectedResolve = null; + _connectedPromise = null; + // Connect promise coalescing + _connectPromise = null; + constructor(config, createRef) { + super(); + this._config = { + defaultChannel: "websocket", + heartbeatInterval: 2e4, + reconnectDelay: 3e3, + reconnectThrottle: 3e3, + 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((r) => { + this._appReadyResolve = r; + }); + } + // ------------------------------------------------------------------------- + // Configuration + // ------------------------------------------------------------------------- + configure(partial) { + Object.assign(this._config, partial); + if (partial.notify !== void 0) this._notify = partial.notify; + if (partial.translate !== void 0) this._translate = partial.translate; + } + /** @internal — called by channels to obtain the current auth token */ + _getAuthToken() { + return this._config.getAuthToken?.(); + } + _isNativePlatform() { + const v = this._config.isNativePlatform; + return typeof v === "function" ? v() : v ?? false; + } + _getUrl() { + const u = this._config.url; + return typeof u === "function" ? u() : u; + } + _t(key, fallback) { + if (this._translate) { + const result = this._translate(key); + if (result) return result; + } + return fallback; + } + _shouldNotify() { + return this._config.showConnectionNotifications !== false && !this._isNativePlatform() && !!this._notify; + } + // ------------------------------------------------------------------------- + // Connection lifecycle + // ------------------------------------------------------------------------- + ensureConnected() { + if (this.socket?.socket_id && this.is_opened.value) return Promise.resolve(); + if (!this._connectedPromise) { + this._connectedPromise = new Promise((r) => { + this._connectedResolve = r; + }); + this.connect().catch(() => { + }); + } + return this._connectedPromise; + } + setAppReady() { + this._appReady = true; + this._appReadyResolve?.(); + } + resetConnection() { + for (const ch of this.channels) { + const c = ch; + c.is_established = false; + c._establishPromise = null; + } + this.is_setup.value = false; + } + async connect(force_reset = false) { + 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 ?? 3e3; + 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; + } + _doConnect(force_reset) { + const hbInterval = this._config.heartbeatInterval ?? 2e4; + 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(); + console.debug("[blax-networking] Connecting to", url); + this.socket = new WebSocket(url); + this.is_connecting_socket.value = true; + this._config.onConnectionStateChange?.("connecting"); + return new Promise((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\u2026"); + this._notify({ id: "websocket-connection-state", type: "info", text, timeout: 5e4 }); + } + this._config.onConnectionStateChange?.("disconnected"); + if (this._appReady && this._config.autoReconnect !== false) { + const delay = this._config.reconnectDelay ?? 3e3; + 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: 1e3 }); + } + this.is_opened.value = true; + this._config.onConnectionStateChange?.("connected"); + socket.send('{"event":"websocket.ping","data":{}}'); + setTimeout(() => { + if (this.socket === socket && !socket.socket_id) { + const wsUrl = this._getUrl(); + console.error( + '[blax-networking] WebSocket opened but server did not send "websocket.connection_established" within 5s.\nConnected to: ' + wsUrl + "\nThis usually means the app key in the URL does not match any app configured on the backend (PUSHER_APP_KEY). Check that the path segment after /app/ matches the server's PUSHER_APP_KEY." + ); + } + }, 5e3); + }); + 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?.(); + this.channel(); + this._workSendQueue(); + } + return; + } + 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 = null) { + channel_name ??= this._config.defaultChannel ?? "websocket"; + const existing = this.channels.find((c) => c.name === channel_name); + return (existing ?? new WebsocketChannel(channel_name, this)).establish(); + } + async _workSendQueue() { + 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(event, data = {}, channel_name = null, progress, _retryOnSubscriptionLost = true) { + if (!this._appReady && !_isProtocolEvent(event)) { + await this._appReadyPromise; + } + channel_name ??= this._config.defaultChannel ?? "websocket"; + if (!this.socket) await this.connect(); + let sendingevent; + 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 }; + if (channel_name && !_isProtocolEvent(event)) { + await this.channel(channel_name); + } + 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((resolve, reject) => { + const cleanup = () => { + this.removeEventListener(sendingevent + ":progress", handler); + this.removeEventListener(sendingevent + ":error", handler); + this.removeEventListener(sendingevent + ":response", handler); + }; + const handler = (m) => { + const msg = m.detail; + const duration = Math.round( + (typeof performance !== "undefined" ? performance.now() : Date.now()) - startTime + ); + 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; + } + if (msg?.event?.includes(sendingevent + ":error") || msg?.event?.includes(sendingevent + ":timeout")) { + cleanup(); + console.log(`[ws] ${sendingevent} failed ${duration}ms`); + reject(msg.data); + return; + } + 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) => { + if (_retryOnSubscriptionLost && error?.message === "Subscription not established") { + const ch = this.channels.find( + (c) => c.name === channel_name + ); + if (ch) { + ch.is_established = false; + ch._establishPromise = null; + } + console.log("[ws] Re-establishing channel after subscription loss"); + return this.send(event, data, channel_name, progress, false); + } + throw error; + }); + } + // ------------------------------------------------------------------------- + // Event listeners (framework-agnostic — return cleanup function) + // ------------------------------------------------------------------------- + listen(event, channel_name, callback) { + channel_name ??= this._config.defaultChannel ?? "websocket"; + const handler = (m) => { + if (m.detail.channel === channel_name) callback(m.detail.data); + }; + this.addEventListener(event, handler); + return () => this.removeEventListener(event, handler); + } + listenOnce(event, channel_name) { + channel_name ??= this._config.defaultChannel ?? "websocket"; + return new Promise((resolve) => { + const handler = (m) => { + if (m.detail.channel === channel_name) { + resolve(m.detail.data); + this.removeEventListener(event, handler); + } + }; + this.addEventListener(event, handler); + }); + } + async unsubscribe(channel_name) { + 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() { + 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; + } +}; +function createWsClient(config, createRef = plainRef) { + const isServer = typeof config.isServer === "function" ? config.isServer() : config.isServer ?? false; + if (isServer) return createSsrStub(createRef); + const url = typeof config.url === "function" ? config.url() : config.url; + if (!url || url === "wss:///app/" || url === "ws:///app/") { + console.error( + "[blax-networking] WebSocket URL is empty or malformed: " + JSON.stringify(url) + "\nEnsure WEBS_URL and PUSHER_APP_KEY are configured. Expected format: wss://your-ws-host/app/{appKey}" + ); + } else if (url.endsWith("/app/") || url.endsWith("/app")) { + console.error( + "[blax-networking] WebSocket URL is missing the app key: " + url + "\nEnsure PUSHER_APP_KEY is set. The URL must end with /app/{appKey} where {appKey} matches the PUSHER_APP_KEY on the backend." + ); + } + return new WsClientImpl(config, createRef); +} + +exports.browserStorage = browserStorage; +exports.createApiClient = createApiClient; +exports.createWsClient = createWsClient; +exports.fetchAdapter = fetchAdapter; +exports.memoryStorage = memoryStorage; +exports.plainRef = plainRef; diff --git a/dist/chunk-TGWHEE6S.js b/dist/chunk-TGWHEE6S.js new file mode 100644 index 0000000..f61dd9c --- /dev/null +++ b/dist/chunk-TGWHEE6S.js @@ -0,0 +1,762 @@ +// src/types.ts +var browserStorage = { + 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 { + } + } +}; +var memoryStorage = () => { + const store = /* @__PURE__ */ new Map(); + return { + get: (key) => store.get(key) ?? null, + set: (key, value) => { + store.set(key, value); + }, + remove: (key) => { + store.delete(key); + } + }; +}; +var plainRef = (initial) => ({ value: initial }); + +// src/api.ts +function serializeParams(params, prefix = "") { + const parts = []; + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) continue; + const value = params[key]; + if (value === void 0 || 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("&"); +} +var fetchAdapter = { + async request(config) { + let url = config.url; + if (config.params) { + const qs = serializeParams(config.params); + if (qs) url += (url.includes("?") ? "&" : "?") + qs; + } + const headers = { + "Content-Type": "application/json", + "Accept": "application/json", + ...config.headers ?? {} + }; + const init = { + method: config.method, + headers, + credentials: config.withCredentials ? "include" : "same-origin" + }; + if (config.timeout && typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) { + init.signal = AbortSignal.timeout(config.timeout); + } + if (config.data !== void 0 && config.method !== "GET") { + if (typeof FormData !== "undefined" && config.data instanceof FormData) { + init.body = config.data; + delete init.headers["Content-Type"]; + } else { + init.body = JSON.stringify(config.data); + } + } + const res = await fetch(url, init); + const contentType = res.headers.get("content-type") ?? ""; + let data; + if (contentType.includes("application/json")) { + data = await res.json(); + } else { + data = await res.text(); + } + if (!res.ok) { + const error = new Error(data?.message ?? `HTTP ${res.status}`); + error.response = { data, status: res.status, headers: {} }; + error.status = res.status; + throw error; + } + const responseHeaders = {}; + res.headers.forEach((v, k) => { + responseHeaders[k] = v; + }); + return { data, status: res.status, headers: responseHeaders }; + } +}; +var ApiClient = class { + _config; + _http; + _storage; + _notify; + _bearerToken = ""; + /** + * When true, `parseError` throws without showing notifications. + * Useful during app bootstrap to suppress transient errors. + */ + silentErrors = false; + constructor(config) { + this._config = { + timeout: 1e4, + 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) { + Object.assign(this._config, partial); + if (partial.http) this._http = partial.http; + if (partial.storage) this._storage = partial.storage; + if (partial.notify !== void 0) this._notify = partial.notify; + } + _isServer() { + const s = this._config.isServer; + return typeof s === "function" ? s() : s ?? false; + } + // ------------------------------------------------------------------------- + // URL helpers + // ------------------------------------------------------------------------- + /** Resolve the current backend base URL (trailing slash guaranteed). */ + getBackendUrl() { + 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) { + 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) { + if (url.startsWith("/")) return url.substring(1); + if (!url.startsWith(this._config.apiPrefix)) return this._config.apiPrefix + url; + return url; + } + // ------------------------------------------------------------------------- + // Auth + // ------------------------------------------------------------------------- + get bearerToken() { + return this._bearerToken; + } + setBearer(token) { + 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() { + if (this._isServer()) return null; + const token = this._storage.get(this._config.storageKey); + if (token) this._bearerToken = token; + return token; + } + // ------------------------------------------------------------------------- + // Internal request plumbing + // ------------------------------------------------------------------------- + _buildHeaders(overrides) { + const base = { + "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; + } + async _request(config, attempt = 0) { + try { + return await this._http.request(config); + } catch (error) { + if (this._config.retryOn503 && error?.status === 503 && attempt < this._config.maxRetries) { + const backoffMs = (attempt + 1) * 1e3; + await new Promise((r) => setTimeout(r, backoffMs)); + return this._request(config, attempt + 1); + } + throw error; + } + } + // ------------------------------------------------------------------------- + // HTTP verbs + // ------------------------------------------------------------------------- + async get(url, params) { + return this._request({ + method: "GET", + url: this.getBackendUrl() + this.cleanseUrl(url), + params, + headers: this._buildHeaders(), + timeout: this._config.timeout, + withCredentials: this._config.withCredentials + }); + } + async post(url, data, headers) { + return this._request({ + method: "POST", + url: this.getBackendUrl() + this.cleanseUrl(url), + data, + headers: this._buildHeaders(headers), + timeout: this._config.timeout, + withCredentials: this._config.withCredentials + }); + } + async put(url, data) { + return this._request({ + method: "PUT", + url: this.getBackendUrl() + this.cleanseUrl(url), + data, + headers: this._buildHeaders(), + timeout: this._config.timeout, + withCredentials: this._config.withCredentials + }); + } + async delete(url, headers) { + return this._request({ + method: "DELETE", + url: this.getBackendUrl() + this.cleanseUrl(url), + headers: this._buildHeaders(headers), + timeout: this._config.timeout, + withCredentials: this._config.withCredentials + }); + } + async patch(url, data) { + return this._request({ + 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") { + 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) => { + 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: 3e3 + }); + } + throw e; + }; + /** + * Show a success notification from a response. + * Usage: `api.post('x').then(api.parseThen)` + */ + parseThen = (res, fallback) => { + const msg = res?.data?.message ?? res?.message ?? fallback ?? "Success"; + if (this._notify) { + this._notify(typeof msg === "string" ? msg : JSON.stringify(msg)); + } + }; +}; +function createApiClient(config) { + return new ApiClient(config); +} + +// src/ws.ts +var _isProtocolEvent = (event) => /[.:](?:subscribe|unsubscribe|ping|pong)$/.test(event); +function createSsrStub(createRef) { + 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(void 0), + send: () => Promise.resolve(null), + unsubscribe: () => Promise.resolve(true), + listen: () => () => { + }, + listenOnce: () => Promise.resolve(null), + setAppReady: () => { + }, + resetConnection: () => { + }, + configure: () => { + }, + destroy: () => { + } + }; +} +var WebsocketChannel = class { + name; + is_established = false; + _establishPromise = null; + _ws; + constructor(name, ws) { + this.name = name; + this._ws = ws; + ws.channels.push(this); + } + async establish() { + if (this.is_established && this._ws.is_setup.value) return this; + this._establishPromise ??= this._doEstablish(); + return this._establishPromise; + } + async _doEstablish() { + try { + await this._ws.ensureConnected(); + const authtoken = this._ws._getAuthToken(); + await this._ws.send( + "websocket.subscribe", + { channel: this.name, authtoken: authtoken ?? void 0 }, + 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, data = {}) { + if (!_isProtocolEvent(event) && !this.is_established) await this.establish(); + return this._ws.send(event, data, _isProtocolEvent(event) ? null : this.name); + } + async unsubscribe() { + 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; + } +}; +var WsClientImpl = class extends EventTarget { + _config; + _notify; + _translate; + socket = null; + channels = []; + is_opened; + is_setup; + is_connecting_socket; + is_after_lost_connection; + heartbeat = null; + last_reconnect_try = 0; + send_queue = []; + // App-readiness gate + _appReady = false; + _appReadyResolve = null; + _appReadyPromise; + // Connection-ready promise + _connectedResolve = null; + _connectedPromise = null; + // Connect promise coalescing + _connectPromise = null; + constructor(config, createRef) { + super(); + this._config = { + defaultChannel: "websocket", + heartbeatInterval: 2e4, + reconnectDelay: 3e3, + reconnectThrottle: 3e3, + 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((r) => { + this._appReadyResolve = r; + }); + } + // ------------------------------------------------------------------------- + // Configuration + // ------------------------------------------------------------------------- + configure(partial) { + Object.assign(this._config, partial); + if (partial.notify !== void 0) this._notify = partial.notify; + if (partial.translate !== void 0) this._translate = partial.translate; + } + /** @internal — called by channels to obtain the current auth token */ + _getAuthToken() { + return this._config.getAuthToken?.(); + } + _isNativePlatform() { + const v = this._config.isNativePlatform; + return typeof v === "function" ? v() : v ?? false; + } + _getUrl() { + const u = this._config.url; + return typeof u === "function" ? u() : u; + } + _t(key, fallback) { + if (this._translate) { + const result = this._translate(key); + if (result) return result; + } + return fallback; + } + _shouldNotify() { + return this._config.showConnectionNotifications !== false && !this._isNativePlatform() && !!this._notify; + } + // ------------------------------------------------------------------------- + // Connection lifecycle + // ------------------------------------------------------------------------- + ensureConnected() { + if (this.socket?.socket_id && this.is_opened.value) return Promise.resolve(); + if (!this._connectedPromise) { + this._connectedPromise = new Promise((r) => { + this._connectedResolve = r; + }); + this.connect().catch(() => { + }); + } + return this._connectedPromise; + } + setAppReady() { + this._appReady = true; + this._appReadyResolve?.(); + } + resetConnection() { + for (const ch of this.channels) { + const c = ch; + c.is_established = false; + c._establishPromise = null; + } + this.is_setup.value = false; + } + async connect(force_reset = false) { + 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 ?? 3e3; + 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; + } + _doConnect(force_reset) { + const hbInterval = this._config.heartbeatInterval ?? 2e4; + 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(); + console.debug("[blax-networking] Connecting to", url); + this.socket = new WebSocket(url); + this.is_connecting_socket.value = true; + this._config.onConnectionStateChange?.("connecting"); + return new Promise((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\u2026"); + this._notify({ id: "websocket-connection-state", type: "info", text, timeout: 5e4 }); + } + this._config.onConnectionStateChange?.("disconnected"); + if (this._appReady && this._config.autoReconnect !== false) { + const delay = this._config.reconnectDelay ?? 3e3; + 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: 1e3 }); + } + this.is_opened.value = true; + this._config.onConnectionStateChange?.("connected"); + socket.send('{"event":"websocket.ping","data":{}}'); + setTimeout(() => { + if (this.socket === socket && !socket.socket_id) { + const wsUrl = this._getUrl(); + console.error( + '[blax-networking] WebSocket opened but server did not send "websocket.connection_established" within 5s.\nConnected to: ' + wsUrl + "\nThis usually means the app key in the URL does not match any app configured on the backend (PUSHER_APP_KEY). Check that the path segment after /app/ matches the server's PUSHER_APP_KEY." + ); + } + }, 5e3); + }); + 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?.(); + this.channel(); + this._workSendQueue(); + } + return; + } + 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 = null) { + channel_name ??= this._config.defaultChannel ?? "websocket"; + const existing = this.channels.find((c) => c.name === channel_name); + return (existing ?? new WebsocketChannel(channel_name, this)).establish(); + } + async _workSendQueue() { + 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(event, data = {}, channel_name = null, progress, _retryOnSubscriptionLost = true) { + if (!this._appReady && !_isProtocolEvent(event)) { + await this._appReadyPromise; + } + channel_name ??= this._config.defaultChannel ?? "websocket"; + if (!this.socket) await this.connect(); + let sendingevent; + 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 }; + if (channel_name && !_isProtocolEvent(event)) { + await this.channel(channel_name); + } + 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((resolve, reject) => { + const cleanup = () => { + this.removeEventListener(sendingevent + ":progress", handler); + this.removeEventListener(sendingevent + ":error", handler); + this.removeEventListener(sendingevent + ":response", handler); + }; + const handler = (m) => { + const msg = m.detail; + const duration = Math.round( + (typeof performance !== "undefined" ? performance.now() : Date.now()) - startTime + ); + 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; + } + if (msg?.event?.includes(sendingevent + ":error") || msg?.event?.includes(sendingevent + ":timeout")) { + cleanup(); + console.log(`[ws] ${sendingevent} failed ${duration}ms`); + reject(msg.data); + return; + } + 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) => { + if (_retryOnSubscriptionLost && error?.message === "Subscription not established") { + const ch = this.channels.find( + (c) => c.name === channel_name + ); + if (ch) { + ch.is_established = false; + ch._establishPromise = null; + } + console.log("[ws] Re-establishing channel after subscription loss"); + return this.send(event, data, channel_name, progress, false); + } + throw error; + }); + } + // ------------------------------------------------------------------------- + // Event listeners (framework-agnostic — return cleanup function) + // ------------------------------------------------------------------------- + listen(event, channel_name, callback) { + channel_name ??= this._config.defaultChannel ?? "websocket"; + const handler = (m) => { + if (m.detail.channel === channel_name) callback(m.detail.data); + }; + this.addEventListener(event, handler); + return () => this.removeEventListener(event, handler); + } + listenOnce(event, channel_name) { + channel_name ??= this._config.defaultChannel ?? "websocket"; + return new Promise((resolve) => { + const handler = (m) => { + if (m.detail.channel === channel_name) { + resolve(m.detail.data); + this.removeEventListener(event, handler); + } + }; + this.addEventListener(event, handler); + }); + } + async unsubscribe(channel_name) { + 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() { + 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; + } +}; +function createWsClient(config, createRef = plainRef) { + const isServer = typeof config.isServer === "function" ? config.isServer() : config.isServer ?? false; + if (isServer) return createSsrStub(createRef); + const url = typeof config.url === "function" ? config.url() : config.url; + if (!url || url === "wss:///app/" || url === "ws:///app/") { + console.error( + "[blax-networking] WebSocket URL is empty or malformed: " + JSON.stringify(url) + "\nEnsure WEBS_URL and PUSHER_APP_KEY are configured. Expected format: wss://your-ws-host/app/{appKey}" + ); + } else if (url.endsWith("/app/") || url.endsWith("/app")) { + console.error( + "[blax-networking] WebSocket URL is missing the app key: " + url + "\nEnsure PUSHER_APP_KEY is set. The URL must end with /app/{appKey} where {appKey} matches the PUSHER_APP_KEY on the backend." + ); + } + return new WsClientImpl(config, createRef); +} + +export { browserStorage, createApiClient, createWsClient, fetchAdapter, memoryStorage, plainRef }; diff --git a/dist/index.cjs b/dist/index.cjs new file mode 100644 index 0000000..b7d45b0 --- /dev/null +++ b/dist/index.cjs @@ -0,0 +1,30 @@ +'use strict'; + +var chunkNJGSOYSN_cjs = require('./chunk-NJGSOYSN.cjs'); + + + +Object.defineProperty(exports, "browserStorage", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.browserStorage; } +}); +Object.defineProperty(exports, "createApiClient", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.createApiClient; } +}); +Object.defineProperty(exports, "createWsClient", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.createWsClient; } +}); +Object.defineProperty(exports, "fetchAdapter", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.fetchAdapter; } +}); +Object.defineProperty(exports, "memoryStorage", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.memoryStorage; } +}); +Object.defineProperty(exports, "plainRef", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.plainRef; } +}); diff --git a/dist/index.d.cts b/dist/index.d.cts new file mode 100644 index 0000000..7843e1f --- /dev/null +++ b/dist/index.d.cts @@ -0,0 +1,112 @@ +import { A as ApiClientConfig, a as HttpResponse, H as HttpAdapter, R as ReactiveRef, W as WsClientConfig, C as CreateRefFn } from './types-C4-WlXk8.cjs'; +export { b as HttpRequestConfig, N as NotifyFn, c as NotifyOptions, S as StorageAdapter, T as TranslateFn, d as browserStorage, m as memoryStorage, p as plainRef } from './types-C4-WlXk8.cjs'; + +declare const fetchAdapter: HttpAdapter; +declare class ApiClient { + private _config; + private _http; + private _storage; + private _notify; + private _bearerToken; + /** + * When true, `parseError` throws without showing notifications. + * Useful during app bootstrap to suppress transient errors. + */ + silentErrors: boolean; + constructor(config: ApiClientConfig); + /** Update configuration at runtime. */ + configure(partial: Partial): void; + private _isServer; + /** Resolve the current backend base URL (trailing slash guaranteed). */ + getBackendUrl(): string; + /** + * 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; + /** + * Normalize a URL path: + * - Leading `/` is stripped (treated as absolute relative to backend root). + * - Otherwise, `apiPrefix` is prepended if absent. + */ + cleanseUrl(url: string): string; + get bearerToken(): string; + setBearer(token: string | null): void; + /** Read the bearer token from storage and activate it. Returns the token or null. */ + loadBearerFromStorage(): string | null; + private _buildHeaders; + private _request; + get(url: string, params?: any): Promise>; + post(url: string, data?: any, headers?: Record): Promise>; + put(url: string, data?: any): Promise>; + delete(url: string, headers?: Record): Promise>; + patch(url: string, data?: any): Promise>; + /** + * Fetch a CSRF cookie. The default path matches Laravel Sanctum + * but you can pass any path your backend uses. + */ + csrf(path?: string): Promise>; + /** + * 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; + /** + * Show a success notification from a response. + * Usage: `api.post('x').then(api.parseThen)` + */ + parseThen: (res: any, fallback?: string) => void; +} +declare function createApiClient(config: ApiClientConfig): ApiClient; + +interface WsSocket extends WebSocket { + socket_id?: string; +} +interface WsChannel { + name: string; + is_established: boolean; + establish(): Promise; + send(event: string, data?: any): Promise; + unsubscribe(): Promise; +} +interface WsClient { + socket: WsSocket | null; + channels: WsChannel[]; + is_opened: ReactiveRef; + is_setup: ReactiveRef; + is_connecting_socket: ReactiveRef; + is_after_lost_connection: ReactiveRef; + heartbeat: ReturnType | null; + last_reconnect_try: number; + send_queue: any[]; + connect(force_reset?: boolean): Promise; + ensureConnected(): Promise; + channel(channel_name?: string | null): Promise; + send(event: string, data?: object, channel_name?: string | null, progress?: (data: any) => void): Promise; + unsubscribe(channel_name?: string | null): Promise; + /** + * 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; + /** 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): void; + /** Close the connection, clear intervals, and reset all state. */ + destroy(): void; +} +/** + * 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. + */ +declare function createWsClient(config: WsClientConfig, createRef?: CreateRefFn): WsClient; + +export { ApiClient, ApiClientConfig, CreateRefFn, HttpAdapter, HttpResponse, ReactiveRef, type WsChannel, type WsClient, WsClientConfig, createApiClient, createWsClient, fetchAdapter }; diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..112282c --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,112 @@ +import { A as ApiClientConfig, a as HttpResponse, H as HttpAdapter, R as ReactiveRef, W as WsClientConfig, C as CreateRefFn } from './types-C4-WlXk8.js'; +export { b as HttpRequestConfig, N as NotifyFn, c as NotifyOptions, S as StorageAdapter, T as TranslateFn, d as browserStorage, m as memoryStorage, p as plainRef } from './types-C4-WlXk8.js'; + +declare const fetchAdapter: HttpAdapter; +declare class ApiClient { + private _config; + private _http; + private _storage; + private _notify; + private _bearerToken; + /** + * When true, `parseError` throws without showing notifications. + * Useful during app bootstrap to suppress transient errors. + */ + silentErrors: boolean; + constructor(config: ApiClientConfig); + /** Update configuration at runtime. */ + configure(partial: Partial): void; + private _isServer; + /** Resolve the current backend base URL (trailing slash guaranteed). */ + getBackendUrl(): string; + /** + * 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; + /** + * Normalize a URL path: + * - Leading `/` is stripped (treated as absolute relative to backend root). + * - Otherwise, `apiPrefix` is prepended if absent. + */ + cleanseUrl(url: string): string; + get bearerToken(): string; + setBearer(token: string | null): void; + /** Read the bearer token from storage and activate it. Returns the token or null. */ + loadBearerFromStorage(): string | null; + private _buildHeaders; + private _request; + get(url: string, params?: any): Promise>; + post(url: string, data?: any, headers?: Record): Promise>; + put(url: string, data?: any): Promise>; + delete(url: string, headers?: Record): Promise>; + patch(url: string, data?: any): Promise>; + /** + * Fetch a CSRF cookie. The default path matches Laravel Sanctum + * but you can pass any path your backend uses. + */ + csrf(path?: string): Promise>; + /** + * 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; + /** + * Show a success notification from a response. + * Usage: `api.post('x').then(api.parseThen)` + */ + parseThen: (res: any, fallback?: string) => void; +} +declare function createApiClient(config: ApiClientConfig): ApiClient; + +interface WsSocket extends WebSocket { + socket_id?: string; +} +interface WsChannel { + name: string; + is_established: boolean; + establish(): Promise; + send(event: string, data?: any): Promise; + unsubscribe(): Promise; +} +interface WsClient { + socket: WsSocket | null; + channels: WsChannel[]; + is_opened: ReactiveRef; + is_setup: ReactiveRef; + is_connecting_socket: ReactiveRef; + is_after_lost_connection: ReactiveRef; + heartbeat: ReturnType | null; + last_reconnect_try: number; + send_queue: any[]; + connect(force_reset?: boolean): Promise; + ensureConnected(): Promise; + channel(channel_name?: string | null): Promise; + send(event: string, data?: object, channel_name?: string | null, progress?: (data: any) => void): Promise; + unsubscribe(channel_name?: string | null): Promise; + /** + * 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; + /** 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): void; + /** Close the connection, clear intervals, and reset all state. */ + destroy(): void; +} +/** + * 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. + */ +declare function createWsClient(config: WsClientConfig, createRef?: CreateRefFn): WsClient; + +export { ApiClient, ApiClientConfig, CreateRefFn, HttpAdapter, HttpResponse, ReactiveRef, type WsChannel, type WsClient, WsClientConfig, createApiClient, createWsClient, fetchAdapter }; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..f4cffe1 --- /dev/null +++ b/dist/index.js @@ -0,0 +1 @@ +export { browserStorage, createApiClient, createWsClient, fetchAdapter, memoryStorage, plainRef } from './chunk-TGWHEE6S.js'; diff --git a/dist/nuxt.cjs b/dist/nuxt.cjs new file mode 100644 index 0000000..c1cc914 --- /dev/null +++ b/dist/nuxt.cjs @@ -0,0 +1,65 @@ +'use strict'; + +var chunkNJGSOYSN_cjs = require('./chunk-NJGSOYSN.cjs'); +var vue = require('vue'); + +function createFromNuxtConfig(options = {}) { + const config = options.runtimeConfig ?? (typeof useRuntimeConfig === "function" ? 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 appKeyConfigKey = options.appKeyConfigKey ?? "PUSHER_APP_KEY"; + const pub = config.public ?? config; + const serverUrl = pub[serverUrlKey] ?? ""; + const serverUrlInternal = pub[serverUrlInternalKey] ?? ""; + const wsUrl = pub[wsUrlKey] ?? ""; + const wsProtocol = pub[wsProtocolKey] ?? "wss"; + const appKey = pub[appKeyConfigKey] ?? "websocket"; + const isServer = undefined ?? false; + const api = chunkNJGSOYSN_cjs.createApiClient({ + serverUrl, + ssrServerUrl: serverUrlInternal || void 0, + isServer: () => isServer, + defaultHeaders: () => { + if (!isServer) return {}; + try { + return useRequestHeaders(["cookie", "x-forwarded-for", "x-real-ip"]) ?? {}; + } catch { + return {}; + } + }, + ...options.apiConfig + }); + const vueRef = (initial) => vue.ref(initial); + if (!wsUrl) { + console.error( + `[blax-networking] Missing WebSocket URL. Set runtimeConfig.public.${wsUrlKey} or the NUXT_PUBLIC_${wsUrlKey} environment variable.` + ); + } + if (!appKey) { + console.error( + `[blax-networking] Missing WebSocket app key. Set runtimeConfig.public.${appKeyConfigKey} or the NUXT_PUBLIC_${appKeyConfigKey} environment variable. This must match PUSHER_APP_KEY on the backend.` + ); + } + const ws = chunkNJGSOYSN_cjs.createWsClient( + { + url: `${wsProtocol === "wss" ? "wss" : "ws"}://${wsUrl}/app/${appKey}`, + appKey, + isServer: () => isServer, + ...options.wsConfig + }, + vueRef + ); + return { api, ws }; +} + +Object.defineProperty(exports, "createApiClient", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.createApiClient; } +}); +Object.defineProperty(exports, "createWsClient", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.createWsClient; } +}); +exports.createFromNuxtConfig = createFromNuxtConfig; diff --git a/dist/nuxt.d.cts b/dist/nuxt.d.cts new file mode 100644 index 0000000..f8c2e7f --- /dev/null +++ b/dist/nuxt.d.cts @@ -0,0 +1,54 @@ +import { ApiClient, WsClient } from './index.cjs'; +export { WsChannel, createApiClient, createWsClient } from './index.cjs'; +import { A as ApiClientConfig, W as WsClientConfig } from './types-C4-WlXk8.cjs'; + +/** + * Options for `createFromNuxtConfig()`. + * + * Override the runtimeConfig key names if your project uses different names. + * All keys read from `useRuntimeConfig().public`. + */ +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; + /** runtimeConfig key for the WebSocket app key (default: `'PUSHER_APP_KEY'`) */ + appKeyConfigKey?: string; + /** + * Pass in `useRuntimeConfig()` from the calling plugin/composable. + * If omitted the function falls back to the Nuxt auto-import (works only + * when Nuxt treats this file as part of the app's auto-import context). + */ + runtimeConfig?: Record; + /** Additional ApiClientConfig overrides */ + apiConfig?: Partial; + /** Additional WsClientConfig overrides */ + wsConfig?: Partial; +} +/** + * 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 } } + * }) + * ``` + */ +declare function createFromNuxtConfig(options?: NuxtNetworkingOptions): { + api: ApiClient; + ws: WsClient; +}; + +export { ApiClient, type NuxtNetworkingOptions, WsClient, createFromNuxtConfig }; diff --git a/dist/nuxt.d.ts b/dist/nuxt.d.ts new file mode 100644 index 0000000..9a52299 --- /dev/null +++ b/dist/nuxt.d.ts @@ -0,0 +1,54 @@ +import { ApiClient, WsClient } from './index.js'; +export { WsChannel, createApiClient, createWsClient } from './index.js'; +import { A as ApiClientConfig, W as WsClientConfig } from './types-C4-WlXk8.js'; + +/** + * Options for `createFromNuxtConfig()`. + * + * Override the runtimeConfig key names if your project uses different names. + * All keys read from `useRuntimeConfig().public`. + */ +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; + /** runtimeConfig key for the WebSocket app key (default: `'PUSHER_APP_KEY'`) */ + appKeyConfigKey?: string; + /** + * Pass in `useRuntimeConfig()` from the calling plugin/composable. + * If omitted the function falls back to the Nuxt auto-import (works only + * when Nuxt treats this file as part of the app's auto-import context). + */ + runtimeConfig?: Record; + /** Additional ApiClientConfig overrides */ + apiConfig?: Partial; + /** Additional WsClientConfig overrides */ + wsConfig?: Partial; +} +/** + * 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 } } + * }) + * ``` + */ +declare function createFromNuxtConfig(options?: NuxtNetworkingOptions): { + api: ApiClient; + ws: WsClient; +}; + +export { ApiClient, type NuxtNetworkingOptions, WsClient, createFromNuxtConfig }; diff --git a/dist/nuxt.js b/dist/nuxt.js new file mode 100644 index 0000000..bfa6d5d --- /dev/null +++ b/dist/nuxt.js @@ -0,0 +1,56 @@ +import { createApiClient, createWsClient } from './chunk-TGWHEE6S.js'; +export { createApiClient, createWsClient } from './chunk-TGWHEE6S.js'; +import { ref } from 'vue'; + +function createFromNuxtConfig(options = {}) { + const config = options.runtimeConfig ?? (typeof useRuntimeConfig === "function" ? 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 appKeyConfigKey = options.appKeyConfigKey ?? "PUSHER_APP_KEY"; + const pub = config.public ?? config; + const serverUrl = pub[serverUrlKey] ?? ""; + const serverUrlInternal = pub[serverUrlInternalKey] ?? ""; + const wsUrl = pub[wsUrlKey] ?? ""; + const wsProtocol = pub[wsProtocolKey] ?? "wss"; + const appKey = pub[appKeyConfigKey] ?? "websocket"; + const isServer = import.meta.server ?? false; + const api = createApiClient({ + serverUrl, + ssrServerUrl: serverUrlInternal || void 0, + isServer: () => isServer, + defaultHeaders: () => { + if (!isServer) return {}; + try { + return useRequestHeaders(["cookie", "x-forwarded-for", "x-real-ip"]) ?? {}; + } catch { + return {}; + } + }, + ...options.apiConfig + }); + const vueRef = (initial) => ref(initial); + if (!wsUrl) { + console.error( + `[blax-networking] Missing WebSocket URL. Set runtimeConfig.public.${wsUrlKey} or the NUXT_PUBLIC_${wsUrlKey} environment variable.` + ); + } + if (!appKey) { + console.error( + `[blax-networking] Missing WebSocket app key. Set runtimeConfig.public.${appKeyConfigKey} or the NUXT_PUBLIC_${appKeyConfigKey} environment variable. This must match PUSHER_APP_KEY on the backend.` + ); + } + const ws = createWsClient( + { + url: `${wsProtocol === "wss" ? "wss" : "ws"}://${wsUrl}/app/${appKey}`, + appKey, + isServer: () => isServer, + ...options.wsConfig + }, + vueRef + ); + return { api, ws }; +} + +export { createFromNuxtConfig }; diff --git a/dist/types-C4-WlXk8.d.cts b/dist/types-C4-WlXk8.d.cts new file mode 100644 index 0000000..4dd2f9b --- /dev/null +++ b/dist/types-C4-WlXk8.d.cts @@ -0,0 +1,126 @@ +interface NotifyOptions { + id?: string | number; + text: string; + type: 'success' | 'error' | 'warning' | 'info'; + timeout?: number; + errors?: any; +} +type NotifyFn = (opts: NotifyOptions | string, type?: NotifyOptions['type']) => void; +type TranslateFn = (key: string) => string | null; +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). */ +declare const browserStorage: StorageAdapter; +/** In-memory storage. Use for SSR or test environments. */ +declare const memoryStorage: () => StorageAdapter; +interface HttpResponse { + data: T; + status: number; + headers: Record; +} +interface HttpAdapter { + request(config: HttpRequestConfig): Promise>; +} +interface HttpRequestConfig { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + url: string; + data?: any; + headers?: Record; + params?: Record; + timeout?: number; + withCredentials?: boolean; +} +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 | (() => Record); + /** + * Whether we are running on the server (SSR). + * Affects URL resolution and storage. + * Default: `false` + */ + isServer?: boolean | (() => boolean); +} +interface WsClientConfig { + /** Full WebSocket URL (e.g. `'wss://example.com/app/mykey'`). String or getter. */ + url: string | (() => string); + /** Application key used in the WebSocket path (e.g. `/app/{appKey}`). */ + appKey?: 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; +} +/** Minimal reactive value container. Compatible with Vue `ref()` or a plain object. */ +interface ReactiveRef { + value: T; +} +/** + * Factory function for creating reactive refs. + * - Vue users pass `ref` from `'vue'` + * - React users can wrap `useState` + * - Others use `plainRef` (default) + */ +type CreateRefFn = (initial: T) => ReactiveRef; +/** Non-reactive ref — plain object with a `.value` property. */ +declare const plainRef: (initial: T) => ReactiveRef; + +export { type ApiClientConfig as A, type CreateRefFn as C, type HttpAdapter as H, type NotifyFn as N, type ReactiveRef as R, type StorageAdapter as S, type TranslateFn as T, type WsClientConfig as W, type HttpResponse as a, type HttpRequestConfig as b, type NotifyOptions as c, browserStorage as d, memoryStorage as m, plainRef as p }; diff --git a/dist/types-C4-WlXk8.d.ts b/dist/types-C4-WlXk8.d.ts new file mode 100644 index 0000000..4dd2f9b --- /dev/null +++ b/dist/types-C4-WlXk8.d.ts @@ -0,0 +1,126 @@ +interface NotifyOptions { + id?: string | number; + text: string; + type: 'success' | 'error' | 'warning' | 'info'; + timeout?: number; + errors?: any; +} +type NotifyFn = (opts: NotifyOptions | string, type?: NotifyOptions['type']) => void; +type TranslateFn = (key: string) => string | null; +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). */ +declare const browserStorage: StorageAdapter; +/** In-memory storage. Use for SSR or test environments. */ +declare const memoryStorage: () => StorageAdapter; +interface HttpResponse { + data: T; + status: number; + headers: Record; +} +interface HttpAdapter { + request(config: HttpRequestConfig): Promise>; +} +interface HttpRequestConfig { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + url: string; + data?: any; + headers?: Record; + params?: Record; + timeout?: number; + withCredentials?: boolean; +} +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 | (() => Record); + /** + * Whether we are running on the server (SSR). + * Affects URL resolution and storage. + * Default: `false` + */ + isServer?: boolean | (() => boolean); +} +interface WsClientConfig { + /** Full WebSocket URL (e.g. `'wss://example.com/app/mykey'`). String or getter. */ + url: string | (() => string); + /** Application key used in the WebSocket path (e.g. `/app/{appKey}`). */ + appKey?: 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; +} +/** Minimal reactive value container. Compatible with Vue `ref()` or a plain object. */ +interface ReactiveRef { + value: T; +} +/** + * Factory function for creating reactive refs. + * - Vue users pass `ref` from `'vue'` + * - React users can wrap `useState` + * - Others use `plainRef` (default) + */ +type CreateRefFn = (initial: T) => ReactiveRef; +/** Non-reactive ref — plain object with a `.value` property. */ +declare const plainRef: (initial: T) => ReactiveRef; + +export { type ApiClientConfig as A, type CreateRefFn as C, type HttpAdapter as H, type NotifyFn as N, type ReactiveRef as R, type StorageAdapter as S, type TranslateFn as T, type WsClientConfig as W, type HttpResponse as a, type HttpRequestConfig as b, type NotifyOptions as c, browserStorage as d, memoryStorage as m, plainRef as p }; diff --git a/dist/vue.cjs b/dist/vue.cjs new file mode 100644 index 0000000..9f79b5a --- /dev/null +++ b/dist/vue.cjs @@ -0,0 +1,70 @@ +'use strict'; + +var chunkNJGSOYSN_cjs = require('./chunk-NJGSOYSN.cjs'); +var vue = require('vue'); + +var vueRef = (initial) => vue.ref(initial); +function createVueWsClient(config) { + const ws = chunkNJGSOYSN_cjs.createWsClient(config, vueRef); + return Object.assign(ws, { + listenWhileMounted(event, channel, callback) { + const off = ws.listen(event, channel, callback); + vue.onUnmounted(off); + return off; + }, + listenOnceWhileMounted(event, channel) { + let off = null; + const promise = new Promise((resolve) => { + off = ws.listen(event, channel, (data) => { + off?.(); + off = null; + resolve(data); + }); + }); + vue.onUnmounted(() => { + off?.(); + }); + return promise; + } + }); +} +function useApiClient(config) { + return chunkNJGSOYSN_cjs.createApiClient(config); +} +function useWsClient(config) { + return chunkNJGSOYSN_cjs.createWsClient(config, vueRef); +} +function useWsListener(ws, event, channel, callback) { + const off = ws.listen(event, channel, callback); + vue.onUnmounted(off); + return off; +} +function useWsListenOnce(ws, event, channel) { + let off = null; + const promise = new Promise((resolve) => { + off = ws.listen(event, channel, (data) => { + off?.(); + off = null; + resolve(data); + }); + }); + vue.onUnmounted(() => { + off?.(); + }); + return promise; +} + +Object.defineProperty(exports, "createApiClient", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.createApiClient; } +}); +Object.defineProperty(exports, "createWsClient", { + enumerable: true, + get: function () { return chunkNJGSOYSN_cjs.createWsClient; } +}); +exports.createVueWsClient = createVueWsClient; +exports.useApiClient = useApiClient; +exports.useWsClient = useWsClient; +exports.useWsListenOnce = useWsListenOnce; +exports.useWsListener = useWsListener; +exports.vueRef = vueRef; diff --git a/dist/vue.d.cts b/dist/vue.d.cts new file mode 100644 index 0000000..2fe0547 --- /dev/null +++ b/dist/vue.d.cts @@ -0,0 +1,59 @@ +import { WsClient, ApiClient } from './index.cjs'; +export { WsChannel, createApiClient, createWsClient } from './index.cjs'; +import { W as WsClientConfig, A as ApiClientConfig, C as CreateRefFn } from './types-C4-WlXk8.cjs'; + +declare const vueRef: CreateRefFn; +/** + * Extended WsClient whose listener methods auto-cleanup on Vue `onUnmounted`. + * + * - `listenWhileMounted(event, channel, cb)` — subscribe for the component's lifetime + * - `listenOnceWhileMounted(event, channel)` — resolve once, auto-cleanup on unmount + * + * The underlying `listen()` / `listenOnce()` remain available for manual control. + */ +interface VueWsClient extends WsClient { + /** Subscribe to `event` on `channel`. Auto-unsubscribes when the calling component unmounts. */ + listenWhileMounted(event: string, channel: string | null | undefined, callback: (data: T) => void): () => void; + /** Resolve once when `event` fires. Auto-cleans up if the component unmounts first. */ + listenOnceWhileMounted(event: string, channel?: string | null): Promise; +} +/** + * Create a WsClient with Vue `ref()` for reactive state and + * `listenWhileMounted` / `listenOnceWhileMounted` convenience methods. + * + * ```ts + * const ws = createVueWsClient({ url: 'wss://…', … }) + * + * // In any component's setup(): + * ws.listenWhileMounted('chat.message', null, (msg) => { … }) + * ``` + */ +declare function createVueWsClient(config: WsClientConfig): VueWsClient; +/** + * Create (or return) an ApiClient. + * Convenience wrapper — you can also call `createApiClient()` directly. + */ +declare function useApiClient(config: ApiClientConfig): ApiClient; +/** + * Create a WsClient with Vue `ref()` for reactive state. + * `ws.is_setup`, `ws.is_opened`, etc. are Vue refs. + * + * @deprecated Prefer `createVueWsClient()` which also provides `listenWhileMounted`. + */ +declare function useWsClient(config: WsClientConfig): WsClient; +/** + * Listen for a WS event with automatic cleanup on component unmount. + * Standalone composable — use this if you prefer functions over instance methods. + * + * ```ts + * useWsListener(ws, 'chat.message', null, (data) => { ... }) + * ``` + */ +declare function useWsListener(ws: WsClient, event: string, channel: string | null | undefined, callback: (data: any) => void): () => void; +/** + * Resolve once when a WS event fires. Cleans up automatically if the component unmounts first. + * Standalone composable — use this if you prefer functions over instance methods. + */ +declare function useWsListenOnce(ws: WsClient, event: string, channel?: string | null): Promise; + +export { ApiClient, type VueWsClient, WsClient, createVueWsClient, useApiClient, useWsClient, useWsListenOnce, useWsListener, vueRef }; diff --git a/dist/vue.d.ts b/dist/vue.d.ts new file mode 100644 index 0000000..2dd48b1 --- /dev/null +++ b/dist/vue.d.ts @@ -0,0 +1,59 @@ +import { WsClient, ApiClient } from './index.js'; +export { WsChannel, createApiClient, createWsClient } from './index.js'; +import { W as WsClientConfig, A as ApiClientConfig, C as CreateRefFn } from './types-C4-WlXk8.js'; + +declare const vueRef: CreateRefFn; +/** + * Extended WsClient whose listener methods auto-cleanup on Vue `onUnmounted`. + * + * - `listenWhileMounted(event, channel, cb)` — subscribe for the component's lifetime + * - `listenOnceWhileMounted(event, channel)` — resolve once, auto-cleanup on unmount + * + * The underlying `listen()` / `listenOnce()` remain available for manual control. + */ +interface VueWsClient extends WsClient { + /** Subscribe to `event` on `channel`. Auto-unsubscribes when the calling component unmounts. */ + listenWhileMounted(event: string, channel: string | null | undefined, callback: (data: T) => void): () => void; + /** Resolve once when `event` fires. Auto-cleans up if the component unmounts first. */ + listenOnceWhileMounted(event: string, channel?: string | null): Promise; +} +/** + * Create a WsClient with Vue `ref()` for reactive state and + * `listenWhileMounted` / `listenOnceWhileMounted` convenience methods. + * + * ```ts + * const ws = createVueWsClient({ url: 'wss://…', … }) + * + * // In any component's setup(): + * ws.listenWhileMounted('chat.message', null, (msg) => { … }) + * ``` + */ +declare function createVueWsClient(config: WsClientConfig): VueWsClient; +/** + * Create (or return) an ApiClient. + * Convenience wrapper — you can also call `createApiClient()` directly. + */ +declare function useApiClient(config: ApiClientConfig): ApiClient; +/** + * Create a WsClient with Vue `ref()` for reactive state. + * `ws.is_setup`, `ws.is_opened`, etc. are Vue refs. + * + * @deprecated Prefer `createVueWsClient()` which also provides `listenWhileMounted`. + */ +declare function useWsClient(config: WsClientConfig): WsClient; +/** + * Listen for a WS event with automatic cleanup on component unmount. + * Standalone composable — use this if you prefer functions over instance methods. + * + * ```ts + * useWsListener(ws, 'chat.message', null, (data) => { ... }) + * ``` + */ +declare function useWsListener(ws: WsClient, event: string, channel: string | null | undefined, callback: (data: any) => void): () => void; +/** + * Resolve once when a WS event fires. Cleans up automatically if the component unmounts first. + * Standalone composable — use this if you prefer functions over instance methods. + */ +declare function useWsListenOnce(ws: WsClient, event: string, channel?: string | null): Promise; + +export { ApiClient, type VueWsClient, WsClient, createVueWsClient, useApiClient, useWsClient, useWsListenOnce, useWsListener, vueRef }; diff --git a/dist/vue.js b/dist/vue.js new file mode 100644 index 0000000..f3a08e0 --- /dev/null +++ b/dist/vue.js @@ -0,0 +1,56 @@ +import { createWsClient, createApiClient } from './chunk-TGWHEE6S.js'; +export { createApiClient, createWsClient } from './chunk-TGWHEE6S.js'; +import { ref, onUnmounted } from 'vue'; + +var vueRef = (initial) => ref(initial); +function createVueWsClient(config) { + const ws = createWsClient(config, vueRef); + return Object.assign(ws, { + listenWhileMounted(event, channel, callback) { + const off = ws.listen(event, channel, callback); + onUnmounted(off); + return off; + }, + listenOnceWhileMounted(event, channel) { + let off = null; + const promise = new Promise((resolve) => { + off = ws.listen(event, channel, (data) => { + off?.(); + off = null; + resolve(data); + }); + }); + onUnmounted(() => { + off?.(); + }); + return promise; + } + }); +} +function useApiClient(config) { + return createApiClient(config); +} +function useWsClient(config) { + return createWsClient(config, vueRef); +} +function useWsListener(ws, event, channel, callback) { + const off = ws.listen(event, channel, callback); + onUnmounted(off); + return off; +} +function useWsListenOnce(ws, event, channel) { + let off = null; + const promise = new Promise((resolve) => { + off = ws.listen(event, channel, (data) => { + off?.(); + off = null; + resolve(data); + }); + }); + onUnmounted(() => { + off?.(); + }); + return promise; +} + +export { createVueWsClient, useApiClient, useWsClient, useWsListenOnce, useWsListener, vueRef };