// 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 };