@yappr/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # @yappr/react
2
+
3
+ A thin React binding for Yappr real-time chat. Wrap your tree in `<YapprProvider>`, then read any channel with one hook:
4
+
5
+ ```tsx
6
+ const { messages, status, unreadCount, hasOlder, send, loadOlder, markRead } =
7
+ useChannel("room1");
8
+ ```
9
+
10
+ ---
11
+
12
+ ## Requirements
13
+
14
+ - **React 18 or 19** (`react`/`react-dom` are peer deps: `^18.0.0 || ^19.0.0`).
15
+ - A reachable **Yappr server** (ws:// or wss://) supplied by your operator.
16
+ - **Client-rendered** — components that call `useChannel` or render `<YapprProvider>` must run in the browser. See [SSR](#ssr).
17
+
18
+ ---
19
+
20
+ ## Install
21
+
22
+ **Once published** (the normal case — nothing special):
23
+
24
+ ```bash
25
+ pnpm add @yappr/react @yappr/core
26
+ ```
27
+
28
+ **Before publish (local tarball testing).** `@yappr/react`'s manifest depends on
29
+ `@yappr/core` by version, so installing the react tarball alone makes pnpm look `@yappr/core`
30
+ up on the npm registry and fail with `ERR_PNPM_FETCH_404`. Drop both tarballs into your project
31
+ root, add a `pnpm` override pinning `@yappr/core` to the local file, then install:
32
+
33
+ ```jsonc
34
+ // package.json
35
+ {
36
+ "dependencies": {
37
+ "@yappr/core": "file:./yappr-core-0.1.0.tgz",
38
+ "@yappr/react": "file:./yappr-react-0.1.0.tgz"
39
+ },
40
+ "pnpm": { "overrides": { "@yappr/core": "file:./yappr-core-0.1.0.tgz" } }
41
+ }
42
+ ```
43
+
44
+ ```bash
45
+ pnpm install
46
+ ```
47
+
48
+ (The override is only needed for local tarballs; once the packages are on npm it is unnecessary.)
49
+
50
+ ---
51
+
52
+ ## Configuration (you must provide these)
53
+
54
+ `createClient` needs four values that come from your Yappr server operator —
55
+ **an AI agent must ASK for these and must not invent them:**
56
+
57
+ | value | what it is | local dev example |
58
+ |------------|---------------------------------------------|-----------------------|
59
+ | `url` | the server origin (ws:// or wss://) | `ws://localhost:8787` |
60
+ | `tenantId` | your tenant id | `t_dev` |
61
+ | `key` | your publishable key | `pk_test_localdev` |
62
+ | channel id | which room to join | `room1` |
63
+
64
+ ---
65
+
66
+ ## Minimal Next.js (App Router) integration
67
+
68
+ Copy this into a `"use client"` component. Substitute the `url`/`tenantId`/`key` your operator gave you and the app's signed-in `userId`.
69
+
70
+ ```tsx
71
+ // app/chat/chat-client.tsx
72
+ "use client";
73
+ import { useState } from "react";
74
+ import { createClient, YapprProvider, useChannel } from "@yappr/react";
75
+
76
+ // Created once at module scope → a stable reference (never inline in render).
77
+ const client = createClient({
78
+ url: "ws://localhost:8787", // ← from your operator
79
+ tenantId: "t_dev", // ← from your operator
80
+ key: "pk_test_localdev", // ← from your operator
81
+ userId: "alice", // the signed-in user; any non-empty string when testing without auth
82
+ displayName: "Alice", // optional
83
+ });
84
+
85
+ export function Chat({ channelId = "room1" }: { channelId?: string }) {
86
+ return (
87
+ <YapprProvider client={client}>
88
+ <Room channelId={channelId} />
89
+ </YapprProvider>
90
+ );
91
+ }
92
+
93
+ function Room({ channelId }: { channelId: string }) {
94
+ const { messages, status, send } = useChannel(channelId);
95
+ const [draft, setDraft] = useState("");
96
+ return (
97
+ <div>
98
+ <p>status: {status}</p>
99
+ <ul>
100
+ {messages.map((m) => (
101
+ <li key={m.id}>
102
+ <b>{m.senderId}:</b> {m.content}
103
+ {m.status !== "sent" && <em> ({m.status})</em>}
104
+ </li>
105
+ ))}
106
+ </ul>
107
+ <form onSubmit={(e) => { e.preventDefault(); if (draft.trim()) { send(draft); setDraft(""); } }}>
108
+ <input value={draft} onChange={(e) => setDraft(e.target.value)} />
109
+ <button type="submit">Send</button>
110
+ </form>
111
+ </div>
112
+ );
113
+ }
114
+ ```
115
+
116
+ A Server Component page just renders `<Chat />`. Everything touching `useChannel` or `<YapprProvider>` must live under a `"use client"` boundary.
117
+
118
+ `@yappr/react` and `@yappr/core` ship compiled JS + types, so no bundler tweaks are needed — you do **not** need `transpilePackages` in `next.config`.
119
+
120
+ ---
121
+
122
+ ## API reference
123
+
124
+ ### `createClient(config): YapprClient`
125
+
126
+ Re-exported from `@yappr/core`. Creates a WebSocket client. Call it **once** — see [Gotchas](#gotchas).
127
+
128
+ | config field | type | required | description |
129
+ |----------------|----------|----------|---------------------------------------|
130
+ | `url` | `string` | yes | WebSocket server origin |
131
+ | `tenantId` | `string` | yes | your tenant id |
132
+ | `key` | `string` | yes | your publishable key |
133
+ | `userId` | `string` | yes | identity of the signed-in user |
134
+ | `displayName` | `string` | no | display name shown to other users |
135
+
136
+ ### `<YapprProvider client={client}>`
137
+
138
+ Puts a `YapprClient` on React context. Stateless — does not open or close the connection.
139
+
140
+ ### `useChannel(channelId: string): UseChannelResult`
141
+
142
+ Subscribes the component to a channel. Returns:
143
+
144
+ | field | type | notes |
145
+ |---------------|---------------------------------------------------------|-------------------------------------------------------------------|
146
+ | `messages` | `Message[]` | ascending; includes your own optimistic sends |
147
+ | `status` | `"connecting" \| "connected" \| "disconnected"` | live connection state |
148
+ | `unreadCount` | `number` | messages since your last read position |
149
+ | `hasOlder` | `boolean` | more history available to page in |
150
+ | `send` | `(content: string) => void` | optimistic; buffered + retried on reconnect if offline. A server *rejection* yields `status: "failed"` (see Gotchas) |
151
+ | `loadOlder` | `() => Promise<void>` | pages older history; resolves when done |
152
+ | `markRead` | `(seq: number) => void` | advances read position (clears unread badge) |
153
+
154
+ `send`, `loadOlder`, and `markRead` are stable across renders.
155
+
156
+ ### Message shape
157
+
158
+ ```ts
159
+ type Message =
160
+ | (Envelope & { status: "sent" }) // confirmed by server — has a `seq` field
161
+ | { status: "sending" | "failed"; // optimistic / not yet confirmed — no `seq`
162
+ id: string; senderId: string; content: string; createdAt: number; kind: "user" }
163
+ ```
164
+
165
+ `seq` only exists on confirmed (`"sent"`) messages. Narrow on `status` before reading it:
166
+
167
+ ```ts
168
+ const newestSeq = messages.reduce(
169
+ (max, m) => (m.status === "sent" && m.seq > max ? m.seq : max),
170
+ 0,
171
+ );
172
+ markRead(newestSeq);
173
+ ```
174
+
175
+ ---
176
+
177
+ ## Gotchas
178
+
179
+ **Stable client — create once.** `useChannel` never closes channels; the client owns connection lifetime. If you write `<YapprProvider client={createClient(...)}>` inline in render, a new socket is opened every render and the old one is orphaned. Create the client at module scope (or in a `useEffect` with a cleanup that calls `client.close()`).
180
+
181
+ **`"use client"` is required.** In Next.js App Router, any component that calls `useChannel` or renders `<YapprProvider>` must be inside a `"use client"` boundary. Server Components cannot use these.
182
+
183
+ **`send` is optimistic.** It returns immediately and the message appears in `messages` with `status: "sending"`. It flips to `status: "sent"` when the server confirms, or `status: "failed"` if rejected. Rendering a retry UI for `"failed"` messages is app-level responsibility — `@yappr/react` surfaces the status but does not render UI for it.
184
+
185
+ **Failed sends are not retried automatically.** A `"failed"` message stays in the list. If you want retry, call `send(m.content)` again when the user triggers it.
186
+
187
+ ---
188
+
189
+ ## Key safety
190
+
191
+ The `key` value (`pk_test_localdev` in local dev) is a publishable key. **Whether it is safe to ship in a browser bundle is a production and operator decision.** The local `pk_test_localdev` is for local development only and should never be used in production.
192
+
193
+ ---
194
+
195
+ ## SSR
196
+
197
+ `useChannel` provides a stable `getServerSnapshot` so server rendering or prerendering does not crash or hydration-mismatch. On the server (and the first hydration render) the snapshot is `{ status: "connecting", messages: [], unreadCount: 0, hasOlder: false }`; the WebSocket is opened only in the browser, and no messages are fetched on the server. In Next.js App Router, mark any component using this package with `"use client"`.
198
+
199
+ ---
200
+
201
+ ## For AI agents
202
+
203
+ 1. Ask the human for `url`, `tenantId`, and `key` (see Configuration). Do not invent them.
204
+ 2. Install the package(s) (see Install).
205
+ 3. Create a `"use client"` component using the snippet above; substitute the provided
206
+ `url`/`tenantId`/`key` and the app's signed-in `userId`.
207
+ 4. Render it from a page. In Next.js App Router, the component (or its wrapper) must be
208
+ `"use client"`.
209
+ 5. Verify: the status shows `connected` and a sent message appears and turns `sent`.
210
+
211
+ ---
212
+
213
+ ## Contributor note (monorepo developers)
214
+
215
+ This package is part of the `yappr` monorepo. For local development and a runnable demo, see `apps/web`. From the repo root:
216
+
217
+ ```bash
218
+ pnpm --filter @yappr/server-app dev:setup # once: migrate + seed tenant t_dev / key pk_test_localdev
219
+ pnpm --filter @yappr/server-app dev # Yappr server on :8787
220
+ pnpm --filter @yappr/web dev # demo app (open the printed URL)
221
+ ```
222
+
223
+ The demo's `ChatPane`/`ChannelView` are a working reference implementation for the patterns in this README.
@@ -0,0 +1,2 @@
1
+ import type { YapprClient } from "@yappr/core";
2
+ export declare const YapprContext: import("react").Context<YapprClient | null>;
@@ -0,0 +1,2 @@
1
+ import { createContext } from "react";
2
+ export const YapprContext = createContext(null);
@@ -0,0 +1,3 @@
1
+ export { YapprProvider, useYapprClient } from "./provider.js";
2
+ export { useChannel, type UseChannelResult } from "./use-channel.js";
3
+ export { createClient, type YapprClient, type YapprClientConfig, type ChannelHandle, type ChannelState, type ConnStatus, type Message, type PendingMessage, type Envelope, type SocketLike, type SocketFactory, } from "@yappr/core";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { YapprProvider, useYapprClient } from "./provider.js";
2
+ export { useChannel } from "./use-channel.js";
3
+ // Re-export the core symbols a consumer needs so they import from one package.
4
+ export { createClient, } from "@yappr/core";
@@ -0,0 +1,17 @@
1
+ import { type ReactNode } from "react";
2
+ import type { YapprClient } from "@yappr/core";
3
+ /**
4
+ * Provides a Yappr client to the `useChannel` hook tree.
5
+ *
6
+ * `client` must be a **stable reference** — create it once with `createClient(...)`
7
+ * (e.g. in module scope or a `useRef`/`useState` initializer), not inline in render.
8
+ * Passing a fresh client on each render makes `useChannel` re-acquire its channel from
9
+ * the new client, opening a new socket while the previous one is orphaned (the binding
10
+ * never closes channels — the client owns connection lifetime). The provider itself is
11
+ * intentionally stateless and runs no effects.
12
+ */
13
+ export declare function YapprProvider({ client, children, }: {
14
+ client: YapprClient;
15
+ children: ReactNode;
16
+ }): import("react").JSX.Element;
17
+ export declare function useYapprClient(): YapprClient;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useContext } from "react";
3
+ import { YapprContext } from "./context.js";
4
+ /**
5
+ * Provides a Yappr client to the `useChannel` hook tree.
6
+ *
7
+ * `client` must be a **stable reference** — create it once with `createClient(...)`
8
+ * (e.g. in module scope or a `useRef`/`useState` initializer), not inline in render.
9
+ * Passing a fresh client on each render makes `useChannel` re-acquire its channel from
10
+ * the new client, opening a new socket while the previous one is orphaned (the binding
11
+ * never closes channels — the client owns connection lifetime). The provider itself is
12
+ * intentionally stateless and runs no effects.
13
+ */
14
+ export function YapprProvider({ client, children, }) {
15
+ return _jsx(YapprContext.Provider, { value: client, children: children });
16
+ }
17
+ export function useYapprClient() {
18
+ const client = useContext(YapprContext);
19
+ if (!client) {
20
+ throw new Error("useYapprClient must be used within a <YapprProvider>");
21
+ }
22
+ return client;
23
+ }
@@ -0,0 +1,11 @@
1
+ import type { ConnStatus, Message } from "@yappr/core";
2
+ export interface UseChannelResult {
3
+ messages: Message[];
4
+ status: ConnStatus;
5
+ unreadCount: number;
6
+ hasOlder: boolean;
7
+ send: (content: string) => void;
8
+ loadOlder: () => Promise<void>;
9
+ markRead: (seq: number) => void;
10
+ }
11
+ export declare function useChannel(channelId: string): UseChannelResult;
@@ -0,0 +1,40 @@
1
+ import { useCallback, useMemo, useSyncExternalStore } from "react";
2
+ import { useYapprClient } from "./provider.js";
3
+ // Single stable references — React requires getServerSnapshot (and the no-handle
4
+ // snapshot) to return identical values across calls, or it re-renders forever.
5
+ // Frozen so a consumer that mutates the empty snapshot can't corrupt the shared
6
+ // sentinel for every other render.
7
+ const EMPTY_MESSAGES = Object.freeze([]);
8
+ const EMPTY_STATE = Object.freeze({
9
+ messages: EMPTY_MESSAGES,
10
+ status: "connecting",
11
+ unreadCount: 0,
12
+ hasOlder: false,
13
+ });
14
+ const NOOP_UNSUB = () => { };
15
+ const SERVER_SNAPSHOT = () => EMPTY_STATE;
16
+ export function useChannel(channelId) {
17
+ const client = useYapprClient();
18
+ // Acquiring the handle eagerly connects, so only do it in the browser.
19
+ // channel() is memoized in core, so this is idempotent per (client, channelId).
20
+ const handle = useMemo(() => (typeof window === "undefined" ? null : client.channel(channelId)), [client, channelId]);
21
+ // Methods are prototype methods using private fields — wrap so `this` is bound
22
+ // and identity is stable per handle (no useSyncExternalStore re-subscription churn).
23
+ const subscribe = useCallback((cb) => (handle ? handle.subscribe(cb) : NOOP_UNSUB), [handle]);
24
+ // getSnapshot identity is irrelevant to useSyncExternalStore (it's read
25
+ // synchronously each render; only `subscribe` identity drives re-subscription),
26
+ // so a plain inline reader is enough. getServerSnapshot is a module constant.
27
+ const state = useSyncExternalStore(subscribe, () => (handle ? handle.getSnapshot() : EMPTY_STATE), SERVER_SNAPSHOT);
28
+ const send = useCallback((content) => handle?.send(content), [handle]);
29
+ const loadOlder = useCallback(() => handle?.loadOlder() ?? Promise.resolve(), [handle]);
30
+ const markRead = useCallback((seq) => handle?.markRead(seq), [handle]);
31
+ return useMemo(() => ({
32
+ messages: state.messages,
33
+ status: state.status,
34
+ unreadCount: state.unreadCount,
35
+ hasOlder: state.hasOlder,
36
+ send,
37
+ loadOlder,
38
+ markRead,
39
+ }), [state, send, loadOlder, markRead]);
40
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@yappr/react",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "dependencies": {
15
+ "@yappr/core": "0.1.0"
16
+ },
17
+ "peerDependencies": {
18
+ "react": "^18.0.0 || ^19.0.0",
19
+ "react-dom": "^18.0.0 || ^19.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@testing-library/react": "^16.1.0",
23
+ "@types/react": "^19.0.0",
24
+ "@types/react-dom": "^19.0.0",
25
+ "happy-dom": "^16.0.0",
26
+ "react": "^19.0.0",
27
+ "react-dom": "^19.0.0",
28
+ "typescript": "5.9.3",
29
+ "vitest": "^3.0.0"
30
+ },
31
+ "scripts": {
32
+ "check": "tsc --noEmit -p tsconfig.json",
33
+ "build": "tsc -p tsconfig.build.json",
34
+ "test": "vitest run"
35
+ },
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js",
40
+ "default": "./dist/index.js"
41
+ }
42
+ }
43
+ }