From 1d8f43148f001ccbcdd8c314fc59539d9165f42c Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 30 Jun 2026 10:56:33 +0200 Subject: [PATCH] docs(agents): add AGENTS.md client reference, ship it via files[], fix ApiClient method names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: canonical client reference — factories + the app `ws` singleton, send() (streaming via the 4th progress arg, no-timeout caveat), listen/listenWhileMounted, the EventTarget mechanics, the setAppReady() gate, the SSR pattern, and a footgun checklist. - package.json: add AGENTS.md to files[] so it ships to node_modules in consumer installs. - README: correct the ApiClient method table — `bearerToken` getter / getBackendUrl() / setBearer(null) (getBearer/getServerUrl/clearBearer never existed) + accurate verb signatures. --- AGENTS.md | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 36 ++++---- package.json | 3 +- 3 files changed, 260 insertions(+), 18 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3d9a85e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,239 @@ +# AGENTS.md — @blax-software/networking + +> Audience: AI coding agents (Claude Code, Copilot, Cursor) and human contributors. +> This is the **canonical, source-verified reference** for *how to use this library's +> WebSocket + API clients*. The README is an overview; **this file is the contract**. +> When they disagree, this file wins (and fix the README). +> +> Claims are grounded in `src/` with file\:line citations. Read the source before you +> trust — don't answer from training-data memory. + +--- + +## 0. Mental model + +This package has **no default export and no built-in singleton.** You call a **factory** +that returns a client. Pick the factory for your environment: + +| Import | Factory | Use for | +|--------|---------|---------| +| `@blax-software/networking` | `createWsClient(config)` / `createApiClient(config)` | Vanilla / React / any framework (plain `{value}` refs) | +| `@blax-software/networking/vue` | `createVueWsClient(config)` | Vue 3 — **reactive** refs + `listenWhileMounted` helpers | +| `@blax-software/networking/nuxt` | `createFromNuxtConfig()` | Nuxt 3 — reads `runtimeConfig.public`, calls `setAppReady()` for you | + +The app creates **one** client and exports it as *its* singleton. In the LATC frontend that is +`export const ws` in `composables/Websocket.client.ts` (built with `createVueWsClient`, Nuxt +auto-imports it — call `ws.send(...)` with no import line). **Never call a factory a second time** — +one socket per app. + +The WS client **is a DOM `EventTarget`**: every inbound message is re-dispatched as a +`CustomEvent` whose `type` is the raw `msg.event` and whose `detail` is `{ event, data, channel }`. +Everything below is sugar over `addEventListener`/`removeEventListener` on that target. + +--- + +## 1. `ws.send()` — request/response (and streaming) + +```ts +send( + event: string, + data?: object, // default {} + channel_name?: string | null, // default null → 'websocket' + progress?: (data: any) => void, // called for each ':progress' frame +): Promise // resolves with the ':response' data +``` +(`src/ws.ts:455`) + +```ts +// simple call — resolves with the UNWRAPPED payload (NOT axios-shaped { data }) +const key = await ws.send('app.stripekey') +const pms = await ws.send('billing.paymentmethods') + +// idiomatic: terse wrapper + shared error toast +export const methodSync = () => ws.send('billing.syncSubscriptions').catch(api.parseError) +``` + +How it works: `send()` rewrites the outgoing event to `[]`, then `addEventListener`s +`:response | :error | :progress` **on itself**, resolving on `:response`, rejecting on +`:error`/`:timeout`, and calling your `progress` callback on `:progress`. On settle it removes all +three listeners (`src/ws.ts:500-504` — this is the `this.removeEventListener(sendingevent + ':progress', handler)` +cleanup). **This is internal** — you never wire those listeners yourself. + +### Streaming — just pass the 4th `progress` arg + +This is the **only** streaming API. Listener cleanup is automatic. Do **not** hand-roll +`addEventListener('…:progress')`. + +```ts +// pair with backend $this->progress(...) +const requestId = ++dataGenerateRequestId // guard against overlapping streams +ws.send('listening.generate', {}, null, (p) => { + if (requestId !== dataGenerateRequestId) return // stale — a newer call superseded us + if (p?.status === 'generating') status.value = t('...') +}).then((final) => { + if (requestId !== dataGenerateRequestId) return + current.value = final +}).catch((e) => { /* e is msg.data, e.g. { message }, NOT an Error */ }) +``` + +⚠️ **Footguns:** +- **No client-side timeout.** If the backend never emits `:response`/`:error`/`:timeout`, + the Promise **hangs forever**. Make sure the handler always replies (return a value, or + `$this->success/error`, or throw). +- **The reject value is `msg.data`**, not an `Error`. Read `e?.message || e?.error`. +- **`setAppReady()` gate** — see §3. Forget it and *every* non-protocol `send()` hangs. +- `data` must be an object. Arrays in params get comma-joined by `serializeParams`; encode + list payloads explicitly if the backend validates strictly. + +--- + +## 2. Listening to server-pushed events (broadcasts) + +For events the **server** pushes on a bare name (no `[rand]` suffix) — `ws_broadcast()` / +`$this->broadcast()` on the backend. + +| API | Returns | Cleanup | +|-----|---------|---------| +| `ws.listen(event, channel, cb)` | `() => void` (off) | you call `off()` | +| `ws.listenOnce(event, channel?)` | `Promise` (resolves once) | self-removes | +| `ws.listenWhileMounted(event, channel, cb)` *(Vue)* | `() => void` | **auto** on `onUnmounted` | +| `ws.listenOnceWhileMounted(event, channel?)` *(Vue)* | `Promise` | auto-cleanup on unmount | +| `useWsListener(ws, event, channel, cb)` *(Vue, standalone)* | `() => void` | auto on `onUnmounted` | + +**Preferred in components:** `listenWhileMounted` (channel filtering + teardown for you). + +```ts +// inside setup() — auto-unsubscribes on unmount +ws.listenWhileMounted<{ message: string }>('info:new_message', channelName, (data) => { + pushMessage(data) +}) + +// global broadcast on the default channel +ws.listenWhileMounted('info:notification', null, (data) => { if (data?.type === '…') reload() }) +``` + +⚠️ **Channel filter is strict equality.** `channel=null|undefined` resolves to the default +`'websocket'` channel; a server event published on a *named* channel won't fire a listener +registered with `null`. Pass the exact channel the server broadcasts on. + +⚠️ `listenWhileMounted`/`useWsListener` call Vue `onUnmounted` — they **must** run synchronously +inside a component `setup()` or the cleanup never binds. + +### Raw `addEventListener` (lower-level / multi-handler) + +Only when you register several related handlers and want one explicit `onUnmounted` block, or you +need the channel string for manual filtering. The handler gets the **CustomEvent** — read +`m.detail.data` (and `m.detail.channel`), not `m.data`. You must keep the handler reference and +`removeEventListener` it yourself or you leak. + +```ts +const handler = (m: any) => { if (m.detail.channel !== wanted) return; use(m.detail.data) } +ws.addEventListener('presence.changed', handler) +onUnmounted(() => ws.removeEventListener('presence.changed', handler)) +``` + +> You cannot `listen()` for a specific `send()`'s response — the `[rand]` suffix is unknown to you. +> Use the Promise that `send()` returns. + +--- + +## 3. Lifecycle — `connect()`, `setAppReady()`, reconnect + +```ts +await ws.connect() // open socket (coalesced + throttled). Usually you DON'T call this — send() auto-connects. +ws.setAppReady() // MANDATORY: unblocks all gated non-protocol send() calls +ws.resetConnection() // after login/logout/token change → next send() re-subscribes channels +ws.destroy() // full teardown +``` + +> **`setAppReady()` is load-bearing.** Until it's called, every non-protocol `send()` awaits an +> internal `_appReadyPromise` **forever**, and auto-reconnect-on-close won't fire. +> `createFromNuxtConfig()` calls it for you. A hand-rolled `createVueWsClient` singleton **must** +> call it once at startup, **after** the bearer/auth is set but **before** any feature `send()`. +> (LATC does this in `app.vue` after `Promise.race([... ws.connect()], 4s cap)`.) + +Feature code should **not** call `connect()`/`setAppReady()` — that's the app bootstrap's job, exactly once. + +Reconnect/heartbeat are built in (don't reinvent): `connect()` throttles via `reconnectThrottle` +(3000ms — bypass for an immediate reconnect by setting `ws.last_reconnect_try = 0`), heartbeat ping +every `heartbeatInterval` (20000ms), auto-reconnect after `reconnectDelay` on close when app-ready. + +--- + +## 4. Reactive state & SSR + +```ts +ws.is_setup // default channel established — safe to send app events +ws.is_opened // socket currently OPEN +ws.is_connecting_socket +ws.is_after_lost_connection +``` + +Read `.value` (`ws.is_setup.value`). **Reactive only** with the `/vue` or `/nuxt` factory; the bare +core gives plain `{ value }` objects (`watch()` won't fire). + +**SSR pattern** (the client is a `.client` module; `ws.send` won't resolve on the server): + +```ts +if (!import.meta.server && ws.is_setup.value) { + return ws.send('aerodrome.index', { locale, page }) // realtime on the client +} else { + return api.get('aerodromes', { page }).then(r => r?.data?.data ?? r?.data ?? null) // REST on the server +} +``` + +--- + +## 5. REST companion — `ApiClient` + +The SSR/fallback path. Verbs return `Promise>` (`{ data, status, headers }`), +auto-attach `Authorization: Bearer`, prepend `apiPrefix` (`'api/'`) unless the path starts with `/`. + +```ts +const api = createApiClient({ serverUrl: 'https://api.example.com' }) +const users = await api.get('users') // GET /api/users +api.get('aerodromes', { page }).catch(api.parseError) // parseError/parseThen are pre-bound +``` + +**Real public methods** (`src/api.ts`): `get(url, params?)`, `post(url, data?, headers?)`, +`put(url, data?)`, `delete(url, headers?)`, `patch(url, data?)`, `csrf(path?)`, `setBearer(token|null)`, +`get bearerToken` (getter), `loadBearerFromStorage()`, `getBackendUrl()`, `clientAsset(path)`, +`cleanseUrl(url)`, `configure(partial)`, `parseError`, `parseThen`. + +> ⚠️ There is **no** `getBearer()`, `getServerUrl()`, or `clearBearer()` (older README listed these). +> Read the token via the `bearerToken` getter; resolve the base URL via `getBackendUrl()`; clear with +> `setBearer(null)`. + +```ts +const ws = createWsClient({ url, getAuthToken: () => api.bearerToken }) // ✅ getter, not getBearer() +``` + +--- + +## 6. Footgun checklist (skim before wiring WS in the UI) + +- [ ] Use the app's exported `ws` singleton — never call a factory twice. +- [ ] `setAppReady()` must run once at startup or every `send()` hangs. +- [ ] `send()` has **no timeout** — a non-replying backend hangs the Promise. +- [ ] `send()` resolves the **unwrapped** payload (not `{ data }`); rejects with `msg.data` (not an Error). +- [ ] Streaming = the 4th `progress` arg of `send()`. Don't hand-wire `:progress` listeners. +- [ ] In components prefer `listenWhileMounted` (auto-teardown + channel filter). +- [ ] `listen()` channel filter is strict; `null` → `'websocket'`. Match the server's channel. +- [ ] Reactive refs need the `/vue` or `/nuxt` factory; always read `.value`. +- [ ] SSR: guard with `!import.meta.server && ws.is_setup.value`, fall back to `api.get`. +- [ ] `ApiClient`: `bearerToken` getter / `getBackendUrl()` / `setBearer(null)` — not the README's old names. + +--- + +## 7. Source map + +| Concern | File | +|---------|------| +| `send` (+ progress), `listen`, `listenOnce`, EventTarget dispatch, cleanup, connect/reconnect | `src/ws.ts` | +| `createVueWsClient`, `listenWhileMounted`, `useWsListener`, reactive refs | `src/vue.ts` | +| `createFromNuxtConfig` | `src/nuxt.ts` | +| `ApiClient` verbs, bearer, `parseError/parseThen` | `src/api.ts` | +| `WsClientConfig`, `ApiClientConfig` | `src/types.ts` | + +**Backend counterpart:** the server side of this protocol (who emits `:progress`/`:response`/`:error`, +`$this->progress`, `ws_broadcast`, channels) is documented in the `laravel-websockets` `AGENTS.md`. diff --git a/README.md b/README.md index d5308c2..1a41883 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ const api = createApiClient({ const ws = createWsClient({ url: 'wss://api.example.com/app/ws', - getAuthToken: () => api.getBearer(), + getAuthToken: () => api.bearerToken, }) // REST @@ -62,7 +62,7 @@ const api = useApiClient({ const ws = useWsClient({ url: 'wss://api.example.com/app/ws', - getAuthToken: () => api.getBearer(), + getAuthToken: () => api.bearerToken, }) // ws.is_setup, ws.is_opened etc. are Vue refs @@ -141,21 +141,23 @@ const api = createApiClient({ **ApiClient methods:** -| Method | Returns | Description | -|-------------------------------|-------------------------|----------------------------------------| -| `get(path, params?)` | `Promise` | GET request | -| `post(path, data?, params?)` | `Promise` | POST request | -| `put(path, data?, params?)` | `Promise` | PUT request | -| `delete(path, params?)` | `Promise` | DELETE request | -| `patch(path, data?, params?)` | `Promise` | PATCH request | -| `csrf(path?)` | `Promise` | Fetch CSRF cookie | -| `setBearer(token)` | `void` | Set auth token | -| `getBearer()` | `string \| null` | Get current auth token | -| `clearBearer()` | `void` | Remove auth token | -| `getServerUrl()` | `string` | Resolve current server URL | -| `parseError(error)` | `never` | Extract error, notify, and re-throw | -| `parseThen(response, msg?)` | `any` | Show success notification, return data | -| `configure(partial)` | `void` | Update config at runtime | +| Method | Returns | Description | +|---------------------------------|-------------------------|----------------------------------------| +| `get(path, params?)` | `Promise` | GET request | +| `post(path, data?, headers?)` | `Promise` | POST request | +| `put(path, data?)` | `Promise` | PUT request | +| `delete(path, headers?)` | `Promise` | DELETE request | +| `patch(path, data?)` | `Promise` | PATCH request | +| `csrf(path?)` | `Promise` | Fetch CSRF cookie | +| `setBearer(token \| null)` | `void` | Set the auth token (pass `null` to clear) | +| `bearerToken` | `string` *(getter)* | Current auth token | +| `loadBearerFromStorage()` | `string \| null` | Load the token from the storage adapter | +| `getBackendUrl()` | `string` | Resolve current server base URL | +| `clientAsset(path)` | `string` | Build a backend asset URL | +| `cleanseUrl(url)` | `string` | Normalize a URL/path | +| `parseError(error)` | `never` | Extract error, notify, and re-throw | +| `parseThen(response, msg?)` | `any` | Show success notification, return data | +| `configure(partial)` | `void` | Update config at runtime | ### `createWsClient(config: WsClientConfig, createRef?): WsClient` diff --git a/package.json b/package.json index 264e7e5..533d214 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ } }, "files": [ - "dist" + "dist", + "AGENTS.md" ], "scripts": { "build": "tsup",