@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 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
+ }
@@ -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;
@@ -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;
@@ -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>;
@@ -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
+ }