@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 +223 -0
- package/dist/context.d.ts +2 -0
- package/dist/context.js +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/provider.d.ts +17 -0
- package/dist/provider.js +23 -0
- package/dist/use-channel.d.ts +11 -0
- package/dist/use-channel.js +40 -0
- package/package.json +43 -0
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.
|
package/dist/context.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -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,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;
|
package/dist/provider.js
ADDED
|
@@ -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
|
+
}
|