@polyphron/viper-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 +98 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +24 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.js +29 -0
- package/dist/realtime.d.ts +10 -0
- package/dist/realtime.js +25 -0
- package/dist/records.d.ts +26 -0
- package/dist/records.js +69 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @polyphron/viper-react
|
|
2
|
+
|
|
3
|
+
React provider and hooks for a [Viper](../../../README.md) backend. A thin
|
|
4
|
+
layer over [`@polyphron/viper-client`](../client/README.md) — everything the client can
|
|
5
|
+
do is still there, `viper.` and all.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @polyphron/viper-react
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
React 18.3 or 19.
|
|
12
|
+
|
|
13
|
+
## Use it
|
|
14
|
+
|
|
15
|
+
Wrap the app once:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { ViperProvider } from "@polyphron/viper-react"
|
|
19
|
+
|
|
20
|
+
createRoot(document.getElementById("root")!).render(
|
|
21
|
+
<ViperProvider url="http://localhost:8090" project="<project id>">
|
|
22
|
+
<App />
|
|
23
|
+
</ViperProvider>
|
|
24
|
+
)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then read records:
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import { useRecords } from "@polyphron/viper-react"
|
|
31
|
+
|
|
32
|
+
function Posts() {
|
|
33
|
+
const { data, loading, error } = useRecords("posts", {
|
|
34
|
+
sort: "-created",
|
|
35
|
+
perPage: 20,
|
|
36
|
+
live: true, // re-fetch whenever a post changes
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (loading) return <p>Loading…</p>
|
|
40
|
+
if (error) return <p>Something went wrong.</p>
|
|
41
|
+
|
|
42
|
+
return <ul>{data?.items.map((p) => <li key={p.id}>{String(p.title)}</li>)}</ul>
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Log in:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { useAuth } from "@polyphron/viper-react"
|
|
50
|
+
|
|
51
|
+
function SignIn() {
|
|
52
|
+
const { user, auth } = useAuth("users")
|
|
53
|
+
|
|
54
|
+
if (user) return <button onClick={() => auth.logout()}>Sign out</button>
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<form
|
|
58
|
+
onSubmit={(e) => {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
const data = new FormData(e.currentTarget)
|
|
61
|
+
auth.loginWithPassword(String(data.get("email")), String(data.get("password")))
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<input name="email" type="email" />
|
|
65
|
+
<input name="password" type="password" />
|
|
66
|
+
<button>Sign in</button>
|
|
67
|
+
</form>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## The hooks
|
|
73
|
+
|
|
74
|
+
| Hook | What it gives you |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| `useViper()` | The `Viper` client — writes go through it: `viper.collection("posts").create(…)` |
|
|
77
|
+
| `useRecords(collection, options)` | One page of records: `{ data, loading, error, reload }` |
|
|
78
|
+
| `useRecord(collection, id, options)` | One record, same shape |
|
|
79
|
+
| `useAuth(collection)` | `{ user, auth }` for an auth collection |
|
|
80
|
+
| `useSession()` / `useUser()` | The session / the logged-in user, re-rendering on change |
|
|
81
|
+
| `useRealtime(topics, onEvent)` | Watch changes yourself; returns the connection status |
|
|
82
|
+
| `useRealtimeStatus()` | `"closed"`, `"connecting"` or `"live"` |
|
|
83
|
+
|
|
84
|
+
`useRecords` and `useRecord` take `live: true` to re-fetch on every change to
|
|
85
|
+
what they are showing. The last good data stays on screen while a reload is in
|
|
86
|
+
flight, so lists don't flash.
|
|
87
|
+
|
|
88
|
+
## Writes
|
|
89
|
+
|
|
90
|
+
There is no mutation hook: the client's own methods are already promises.
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
const viper = useViper()
|
|
94
|
+
const { reload } = useRecords("posts")
|
|
95
|
+
|
|
96
|
+
await viper.collection("posts").create({ title: "Hello" })
|
|
97
|
+
reload() // or pass live: true and let realtime do it
|
|
98
|
+
```
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AuthService, RecordModel, Session } from "@polyphron/viper-client";
|
|
2
|
+
/** The current session, re-rendering on login, logout and refresh. */
|
|
3
|
+
export declare function useSession(): Session | null;
|
|
4
|
+
/** The logged-in user, or null when signed out. */
|
|
5
|
+
export declare function useUser<T extends RecordModel = RecordModel>(): T | null;
|
|
6
|
+
/**
|
|
7
|
+
* The logins of one auth collection, plus the user they belong to.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const { user, auth } = useAuth("users")
|
|
11
|
+
* await auth.loginWithPassword(email, password)
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export declare function useAuth<T extends RecordModel = RecordModel>(collection: string): {
|
|
15
|
+
user: T | null;
|
|
16
|
+
auth: AuthService<T>;
|
|
17
|
+
};
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { useViper } from "./provider.js";
|
|
3
|
+
/** The current session, re-rendering on login, logout and refresh. */
|
|
4
|
+
export function useSession() {
|
|
5
|
+
const viper = useViper();
|
|
6
|
+
return useSyncExternalStore((listener) => viper.onSessionChange(listener), () => viper.session, () => null);
|
|
7
|
+
}
|
|
8
|
+
/** The logged-in user, or null when signed out. */
|
|
9
|
+
export function useUser() {
|
|
10
|
+
return useSession()?.record ?? null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The logins of one auth collection, plus the user they belong to.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const { user, auth } = useAuth("users")
|
|
17
|
+
* await auth.loginWithPassword(email, password)
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function useAuth(collection) {
|
|
21
|
+
const viper = useViper();
|
|
22
|
+
const user = useUser();
|
|
23
|
+
return { user, auth: viper.collection(collection).auth };
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ViperProvider, useViper, type ViperProviderProps } from "./provider.js";
|
|
2
|
+
export { useAuth, useSession, useUser } from "./auth.js";
|
|
3
|
+
export { useRealtime, useRealtimeStatus } from "./realtime.js";
|
|
4
|
+
export { useRecord, useRecords, type Query, type RecordsOptions, } from "./records.js";
|
|
5
|
+
export { Viper, ViperError, fieldError, memoryStorage, type ListOptions, type ListResult, type RealtimeEvent, type RealtimeStatus, type RecordInput, type RecordModel, type Session, type ViperOptions, } from "@polyphron/viper-client";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { ViperProvider, useViper } from "./provider.js";
|
|
2
|
+
export { useAuth, useSession, useUser } from "./auth.js";
|
|
3
|
+
export { useRealtime, useRealtimeStatus } from "./realtime.js";
|
|
4
|
+
export { useRecord, useRecords, } from "./records.js";
|
|
5
|
+
// Re-exported so an app only needs @polyphron/viper-react in its imports.
|
|
6
|
+
export { Viper, ViperError, fieldError, memoryStorage, } from "@polyphron/viper-client";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Viper, type ViperOptions } from "@polyphron/viper-client";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
export type ViperProviderProps = {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
} & ({
|
|
6
|
+
/** An already-built client. Use this when the app makes one at start-up. */
|
|
7
|
+
client: Viper;
|
|
8
|
+
} | ViperOptions);
|
|
9
|
+
/**
|
|
10
|
+
* Puts a Viper client in context so every hook below can reach it.
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <ViperProvider url="http://localhost:8090" project="abc123">
|
|
14
|
+
* <App />
|
|
15
|
+
* </ViperProvider>
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function ViperProvider(props: ViperProviderProps): import("react").JSX.Element;
|
|
19
|
+
/** The client from the nearest ViperProvider. */
|
|
20
|
+
export declare function useViper(): Viper;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Viper } from "@polyphron/viper-client";
|
|
3
|
+
import { createContext, useContext, useState } from "react";
|
|
4
|
+
const ViperContext = createContext(null);
|
|
5
|
+
/**
|
|
6
|
+
* Puts a Viper client in context so every hook below can reach it.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <ViperProvider url="http://localhost:8090" project="abc123">
|
|
10
|
+
* <App />
|
|
11
|
+
* </ViperProvider>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function ViperProvider(props) {
|
|
15
|
+
const { children, ...rest } = props;
|
|
16
|
+
// A client owns a session and a WebSocket, so it is built once and kept.
|
|
17
|
+
// The options are read on the first render only, like any constructor's.
|
|
18
|
+
const [built] = useState(() => "client" in rest ? null : new Viper(rest));
|
|
19
|
+
const client = "client" in rest ? rest.client : built;
|
|
20
|
+
return _jsx(ViperContext, { value: client, children: children });
|
|
21
|
+
}
|
|
22
|
+
/** The client from the nearest ViperProvider. */
|
|
23
|
+
export function useViper() {
|
|
24
|
+
const client = useContext(ViperContext);
|
|
25
|
+
if (!client) {
|
|
26
|
+
throw new Error("useViper must be used inside a <ViperProvider>.");
|
|
27
|
+
}
|
|
28
|
+
return client;
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RealtimeEvent, RealtimeStatus, RecordModel } from "@polyphron/viper-client";
|
|
2
|
+
/**
|
|
3
|
+
* Watches topics and calls `onEvent` for each change. A topic is a collection
|
|
4
|
+
* name for every record in it (`posts`), or `collection/id` for one record.
|
|
5
|
+
*
|
|
6
|
+
* Returns the connection status. The subscription is dropped on unmount.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useRealtime<T extends RecordModel = RecordModel>(topics: string | string[], onEvent: (event: RealtimeEvent<T>) => void): RealtimeStatus;
|
|
9
|
+
/** The realtime connection status, on its own. */
|
|
10
|
+
export declare function useRealtimeStatus(): RealtimeStatus;
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { useViper } from "./provider.js";
|
|
3
|
+
/**
|
|
4
|
+
* Watches topics and calls `onEvent` for each change. A topic is a collection
|
|
5
|
+
* name for every record in it (`posts`), or `collection/id` for one record.
|
|
6
|
+
*
|
|
7
|
+
* Returns the connection status. The subscription is dropped on unmount.
|
|
8
|
+
*/
|
|
9
|
+
export function useRealtime(topics, onEvent) {
|
|
10
|
+
const viper = useViper();
|
|
11
|
+
// Keep the latest callback without resubscribing when it changes.
|
|
12
|
+
const onEventRef = useRef(onEvent);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
onEventRef.current = onEvent;
|
|
15
|
+
});
|
|
16
|
+
// A new array literal every render would resubscribe every render.
|
|
17
|
+
const key = (Array.isArray(topics) ? topics : [topics]).join("\n");
|
|
18
|
+
useEffect(() => viper.realtime.subscribe(key.split("\n"), (e) => onEventRef.current(e)), [viper, key]);
|
|
19
|
+
return useRealtimeStatus();
|
|
20
|
+
}
|
|
21
|
+
/** The realtime connection status, on its own. */
|
|
22
|
+
export function useRealtimeStatus() {
|
|
23
|
+
const viper = useViper();
|
|
24
|
+
return useSyncExternalStore((listener) => viper.realtime.onStatus(listener), () => viper.realtime.status, () => "closed");
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ListOptions, ListResult, RecordModel } from "@polyphron/viper-client";
|
|
2
|
+
export type Query<T> = {
|
|
3
|
+
data: T | null;
|
|
4
|
+
error: unknown;
|
|
5
|
+
/** True until the first answer arrives, and again during a `reload`. */
|
|
6
|
+
loading: boolean;
|
|
7
|
+
/** Fetches again now. */
|
|
8
|
+
reload: () => void;
|
|
9
|
+
};
|
|
10
|
+
export type RecordsOptions = ListOptions & {
|
|
11
|
+
/** Re-fetch when anything in the collection changes. Off by default. */
|
|
12
|
+
live?: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* One page of records, re-fetched when the options change.
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* const { data, loading } = useRecords("posts", { sort: "-created", live: true })
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function useRecords<T extends RecordModel = RecordModel>(collection: string, options?: RecordsOptions): Query<ListResult<T>>;
|
|
22
|
+
/** One record, re-fetched when the id changes. */
|
|
23
|
+
export declare function useRecord<T extends RecordModel = RecordModel>(collection: string, id: string | undefined, options?: {
|
|
24
|
+
expand?: string;
|
|
25
|
+
live?: boolean;
|
|
26
|
+
}): Query<T>;
|
package/dist/records.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { useViper } from "./provider.js";
|
|
3
|
+
import { useRealtime } from "./realtime.js";
|
|
4
|
+
/**
|
|
5
|
+
* One page of records, re-fetched when the options change.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* const { data, loading } = useRecords("posts", { sort: "-created", live: true })
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export function useRecords(collection, options = {}) {
|
|
12
|
+
const viper = useViper();
|
|
13
|
+
const { live = false, ...list } = options;
|
|
14
|
+
// Options are a fresh object every render; compare them by value.
|
|
15
|
+
const key = JSON.stringify(list);
|
|
16
|
+
const query = useFetch(useCallback((signal) => viper
|
|
17
|
+
.collection(collection)
|
|
18
|
+
.list({ ...JSON.parse(key), signal }), [viper, collection, key]));
|
|
19
|
+
useLiveReload(live, collection, query.reload);
|
|
20
|
+
return query;
|
|
21
|
+
}
|
|
22
|
+
/** One record, re-fetched when the id changes. */
|
|
23
|
+
export function useRecord(collection, id, options = {}) {
|
|
24
|
+
const viper = useViper();
|
|
25
|
+
const { live = false, expand } = options;
|
|
26
|
+
const query = useFetch(useCallback((signal) => id
|
|
27
|
+
? viper.collection(collection).view(id, { expand, signal })
|
|
28
|
+
: Promise.resolve(null), [viper, collection, id, expand]));
|
|
29
|
+
useLiveReload(live && Boolean(id), `${collection}/${id}`, query.reload);
|
|
30
|
+
return query;
|
|
31
|
+
}
|
|
32
|
+
// Runs `run` whenever it changes, and gives back its state. Answers from a
|
|
33
|
+
// call that has already been replaced are dropped, so a slow first request
|
|
34
|
+
// can't overwrite a fast second one.
|
|
35
|
+
//
|
|
36
|
+
// `loading` is worked out rather than stored: the answer carries the request
|
|
37
|
+
// it belongs to, so anything older than the current request means "still
|
|
38
|
+
// waiting" — and no state has to be set while the effect is starting.
|
|
39
|
+
function useFetch(run) {
|
|
40
|
+
const [attempt, setAttempt] = useState(0);
|
|
41
|
+
const [answer, setAnswer] = useState({ run: null, attempt: -1, data: null, error: null });
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
run(controller.signal).then((data) => {
|
|
45
|
+
if (!controller.signal.aborted) {
|
|
46
|
+
setAnswer({ run, attempt, data, error: null });
|
|
47
|
+
}
|
|
48
|
+
}, (error) => {
|
|
49
|
+
if (!controller.signal.aborted) {
|
|
50
|
+
setAnswer({ run, attempt, data: null, error });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return () => controller.abort();
|
|
54
|
+
}, [run, attempt]);
|
|
55
|
+
const current = answer.run === run && answer.attempt === attempt;
|
|
56
|
+
return {
|
|
57
|
+
// The last good data stays on screen while a reload is in flight.
|
|
58
|
+
data: answer.data,
|
|
59
|
+
error: current ? answer.error : null,
|
|
60
|
+
loading: !current,
|
|
61
|
+
reload: useCallback(() => setAttempt((n) => n + 1), []),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// Reloads on every realtime event for `topic`, when `live` is on. The hook
|
|
65
|
+
// below is always called, because hooks can't be conditional; an empty topic
|
|
66
|
+
// means no subscription.
|
|
67
|
+
function useLiveReload(live, topic, reload) {
|
|
68
|
+
useRealtime(live ? topic : [], reload);
|
|
69
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@polyphron/viper-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React provider and hooks for the Viper app API.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
22
|
+
"prepack": "npm run build && node ../publish-exports.mjs pre",
|
|
23
|
+
"postpack": "node ../publish-exports.mjs post",
|
|
24
|
+
"lint": "eslint",
|
|
25
|
+
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@polyphron/viper-client": "^0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^18.3.0 || ^19.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@eslint/js": "^10",
|
|
36
|
+
"@types/node": "^24",
|
|
37
|
+
"@types/react": "^19",
|
|
38
|
+
"eslint": "^10",
|
|
39
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
40
|
+
"react": "^19.2.8",
|
|
41
|
+
"typescript": "~6",
|
|
42
|
+
"typescript-eslint": "^8"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
}
|
|
47
|
+
}
|