@covenant-rpc/react 0.1.3
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/CHANGELOG.md +16 -0
- package/CLAUDE.md +107 -0
- package/README.md +15 -0
- package/helpers.ts +105 -0
- package/index.ts +210 -0
- package/package.json +20 -0
- package/tsconfig.json +29 -0
package/CHANGELOG.md
ADDED
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
|
|
3
|
+
Default to using Bun instead of Node.js.
|
|
4
|
+
|
|
5
|
+
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
|
6
|
+
- Use `bun test` instead of `jest` or `vitest`
|
|
7
|
+
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
|
8
|
+
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
|
9
|
+
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
|
10
|
+
- Bun automatically loads .env, so don't use dotenv.
|
|
11
|
+
|
|
12
|
+
## APIs
|
|
13
|
+
|
|
14
|
+
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
|
15
|
+
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
|
16
|
+
- `Bun.redis` for Redis. Don't use `ioredis`.
|
|
17
|
+
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
|
18
|
+
- `WebSocket` is built-in. Don't use `ws`.
|
|
19
|
+
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
|
20
|
+
- Bun.$`ls` instead of execa.
|
|
21
|
+
|
|
22
|
+
## Testing
|
|
23
|
+
|
|
24
|
+
Use `bun test` to run tests.
|
|
25
|
+
|
|
26
|
+
```ts#index.test.ts
|
|
27
|
+
import { test, expect } from "bun:test";
|
|
28
|
+
|
|
29
|
+
test("hello world", () => {
|
|
30
|
+
expect(1).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Frontend
|
|
35
|
+
|
|
36
|
+
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
|
37
|
+
|
|
38
|
+
Server:
|
|
39
|
+
|
|
40
|
+
```ts#index.ts
|
|
41
|
+
import index from "./index.html"
|
|
42
|
+
|
|
43
|
+
Bun.serve({
|
|
44
|
+
routes: {
|
|
45
|
+
"/": index,
|
|
46
|
+
"/api/users/:id": {
|
|
47
|
+
GET: (req) => {
|
|
48
|
+
return new Response(JSON.stringify({ id: req.params.id }));
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
// optional websocket support
|
|
53
|
+
websocket: {
|
|
54
|
+
open: (ws) => {
|
|
55
|
+
ws.send("Hello, world!");
|
|
56
|
+
},
|
|
57
|
+
message: (ws, message) => {
|
|
58
|
+
ws.send(message);
|
|
59
|
+
},
|
|
60
|
+
close: (ws) => {
|
|
61
|
+
// handle close
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
development: {
|
|
65
|
+
hmr: true,
|
|
66
|
+
console: true,
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
|
72
|
+
|
|
73
|
+
```html#index.html
|
|
74
|
+
<html>
|
|
75
|
+
<body>
|
|
76
|
+
<h1>Hello, world!</h1>
|
|
77
|
+
<script type="module" src="./frontend.tsx"></script>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
With the following `frontend.tsx`:
|
|
83
|
+
|
|
84
|
+
```tsx#frontend.tsx
|
|
85
|
+
import React from "react";
|
|
86
|
+
|
|
87
|
+
// import .css files directly and it works
|
|
88
|
+
import './index.css';
|
|
89
|
+
|
|
90
|
+
import { createRoot } from "react-dom/client";
|
|
91
|
+
|
|
92
|
+
const root = createRoot(document.body);
|
|
93
|
+
|
|
94
|
+
export default function Frontend() {
|
|
95
|
+
return <h1>Hello, world!</h1>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
root.render(<Frontend />);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then, run index.ts
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
bun --hot ./index.ts
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @covenant/react
|
|
2
|
+
|
|
3
|
+
To install dependencies:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun install
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
To run:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun run index.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This project was created using `bun init` in bun v1.2.19. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
package/helpers.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { AsyncHook } from ".";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
// definition for AsyncHook:
|
|
4
|
+
// export type AsyncHook<T> = {
|
|
5
|
+
// loading: true,
|
|
6
|
+
// data: null,
|
|
7
|
+
// error: null,
|
|
8
|
+
// } | {
|
|
9
|
+
// loading: false,
|
|
10
|
+
// data: T,
|
|
11
|
+
// error: null,
|
|
12
|
+
// } | {
|
|
13
|
+
// loading: false,
|
|
14
|
+
// data: null,
|
|
15
|
+
// error: ReactProcedureError,
|
|
16
|
+
// }
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export type CallbackFunction<T> = () => T;
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
// useDataStream is best described by an example. Let's say we have a chatFunction which has two methods:
|
|
23
|
+
//
|
|
24
|
+
// getCurrentMessages: () => Promise<Message[]>
|
|
25
|
+
// connectToUpdates: (onUpdate: () => Message): Promise<void>
|
|
26
|
+
// disconnect: () => void
|
|
27
|
+
//
|
|
28
|
+
// We want to fetch all messages as well as continue to listen to them. useDataStream should handle this
|
|
29
|
+
// as well as various race conditions
|
|
30
|
+
|
|
31
|
+
export function useDataStream<T>({ initialFetch, connect, disconnect }: {
|
|
32
|
+
initialFetch: () => Promise<T[]>,
|
|
33
|
+
connect: (callback: CallbackFunction<T>) => Promise<void>,
|
|
34
|
+
disconnect: () => void
|
|
35
|
+
}): AsyncHook<T[]> {
|
|
36
|
+
const [state, setState] = useState<AsyncHook<T[]>>({
|
|
37
|
+
loading: true,
|
|
38
|
+
data: null,
|
|
39
|
+
error: null
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let isMounted = true;
|
|
44
|
+
let items: T[] = [];
|
|
45
|
+
let isConnecting = false;
|
|
46
|
+
|
|
47
|
+
const handleNewItem = (newItem: T) => {
|
|
48
|
+
if (!isMounted) return;
|
|
49
|
+
|
|
50
|
+
// Add new item to the list
|
|
51
|
+
items = [...items, newItem];
|
|
52
|
+
setState({
|
|
53
|
+
loading: false,
|
|
54
|
+
data: items,
|
|
55
|
+
error: null
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const initialize = async () => {
|
|
60
|
+
try {
|
|
61
|
+
// Fetch initial data first
|
|
62
|
+
const initialData = await initialFetch();
|
|
63
|
+
|
|
64
|
+
if (!isMounted) return;
|
|
65
|
+
|
|
66
|
+
items = initialData;
|
|
67
|
+
setState({
|
|
68
|
+
loading: false,
|
|
69
|
+
data: items,
|
|
70
|
+
error: null
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Connect to updates
|
|
74
|
+
// Note: The type signature indicates CallbackFunction<T> = () => T,
|
|
75
|
+
// but the practical use case requires (newItem: T) => void
|
|
76
|
+
// Using type assertion to work with the expected runtime behavior
|
|
77
|
+
isConnecting = true;
|
|
78
|
+
await connect(handleNewItem as any);
|
|
79
|
+
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (!isMounted) return;
|
|
82
|
+
|
|
83
|
+
setState({
|
|
84
|
+
loading: false,
|
|
85
|
+
data: null,
|
|
86
|
+
error: {
|
|
87
|
+
code: 500,
|
|
88
|
+
message: error instanceof Error ? error.message : 'Unknown error'
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
initialize();
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
isMounted = false;
|
|
98
|
+
if (isConnecting) {
|
|
99
|
+
disconnect();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}, [initialFetch, connect, disconnect]);
|
|
103
|
+
|
|
104
|
+
return state;
|
|
105
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { ProcedureMap, ChannelMap } from "@covenant-rpc/core";
|
|
2
|
+
import { InferChannelServerMessage, type InferChannelConnectionRequest, type InferChannelParams } from "@covenant-rpc/core/channel";
|
|
3
|
+
import { CovenantClient, type MutationKey, type QueryKey } from "@covenant-rpc/client";
|
|
4
|
+
import type { InferProcedureInputs, InferProcedureOutputs } from "@covenant-rpc/core/procedure";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export interface ReactProcedureError {
|
|
9
|
+
code: number;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AsyncHook<T> = {
|
|
14
|
+
loading: true,
|
|
15
|
+
data: null,
|
|
16
|
+
error: null,
|
|
17
|
+
} | {
|
|
18
|
+
loading: false,
|
|
19
|
+
data: T,
|
|
20
|
+
error: null,
|
|
21
|
+
} | {
|
|
22
|
+
loading: false,
|
|
23
|
+
data: null,
|
|
24
|
+
error: ReactProcedureError,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Listener = (k: AsyncHook<any>) => void;
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export class CovenantReactClient<P extends ProcedureMap, C extends ChannelMap> extends CovenantClient<P, C> {
|
|
31
|
+
// this is any becuse we have no other choice. You just gotta trust me on this one.
|
|
32
|
+
private cache: Map<string, AsyncHook<any>> = new Map();
|
|
33
|
+
private listenes: Map<string, Listener[]> = new Map();
|
|
34
|
+
|
|
35
|
+
useQuery<Q extends QueryKey<P>>(procedureName: Q, inputs: InferProcedureInputs<P[Q]>): AsyncHook<InferProcedureOutputs<P[Q]>> {
|
|
36
|
+
const [state, setState] = useState<AsyncHook<InferProcedureOutputs<P[Q]>>>({
|
|
37
|
+
loading: true,
|
|
38
|
+
data: null,
|
|
39
|
+
error: null,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const fn = async () => {
|
|
44
|
+
setState({
|
|
45
|
+
loading: true,
|
|
46
|
+
data: null,
|
|
47
|
+
error: null,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const response = await this.query(procedureName, inputs);
|
|
51
|
+
|
|
52
|
+
if (response.success) {
|
|
53
|
+
setState({
|
|
54
|
+
loading: false,
|
|
55
|
+
data: response.data,
|
|
56
|
+
error: null,
|
|
57
|
+
});
|
|
58
|
+
} else {
|
|
59
|
+
setState({
|
|
60
|
+
loading: false,
|
|
61
|
+
data: null,
|
|
62
|
+
error: response.error,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
fn();
|
|
67
|
+
}, [inputs]);
|
|
68
|
+
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
useMutation<Q extends MutationKey<P>>(procedureName: Q, inputs: InferProcedureInputs<P[Q]>): AsyncHook<InferProcedureOutputs<P[Q]>> {
|
|
73
|
+
const [state, setState] = useState<AsyncHook<InferProcedureOutputs<P[Q]>>>({
|
|
74
|
+
loading: true,
|
|
75
|
+
data: null,
|
|
76
|
+
error: null,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const fn = async () => {
|
|
81
|
+
setState({
|
|
82
|
+
loading: true,
|
|
83
|
+
data: null,
|
|
84
|
+
error: null,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const response = await this.mutate(procedureName, inputs);
|
|
88
|
+
|
|
89
|
+
if (response.success) {
|
|
90
|
+
setState({
|
|
91
|
+
loading: false,
|
|
92
|
+
data: response.data,
|
|
93
|
+
error: null,
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
setState({
|
|
97
|
+
loading: false,
|
|
98
|
+
data: null,
|
|
99
|
+
error: response.error,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
fn();
|
|
104
|
+
}, [inputs]);
|
|
105
|
+
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
useListenedQuery<Q extends QueryKey<P>>(procedureName: Q, inputs: InferProcedureInputs<P[Q]>): AsyncHook<InferProcedureOutputs<P[Q]>> {
|
|
110
|
+
const [state, setState] = useState<AsyncHook<InferProcedureOutputs<P[Q]>>>({
|
|
111
|
+
loading: true,
|
|
112
|
+
data: null,
|
|
113
|
+
error: null,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
return this.listen(procedureName, inputs, ({ data, error }) => {
|
|
119
|
+
|
|
120
|
+
if (error !== null) {
|
|
121
|
+
setState({
|
|
122
|
+
loading: false,
|
|
123
|
+
error: error,
|
|
124
|
+
data: null,
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
setState({
|
|
128
|
+
loading: false,
|
|
129
|
+
error: null,
|
|
130
|
+
data: data,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}, [inputs])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private createCachedQuery<Q extends QueryKey<P>>(procedureName: Q, inputs: InferProcedureInputs<P[Q]>) {
|
|
141
|
+
this.listen(procedureName, inputs, ({ data, error }) => {
|
|
142
|
+
const state: AsyncHook<InferProcedureOutputs<P[Q]>> = error === null ? {
|
|
143
|
+
loading: false,
|
|
144
|
+
error: null,
|
|
145
|
+
data: data
|
|
146
|
+
} : {
|
|
147
|
+
loading: false,
|
|
148
|
+
error: error,
|
|
149
|
+
data: null,
|
|
150
|
+
}
|
|
151
|
+
const key = this.getCacheKey(String(procedureName), inputs);
|
|
152
|
+
|
|
153
|
+
const listeners = this.listenes.get(key) ?? [];
|
|
154
|
+
this.cache.set(key, state);
|
|
155
|
+
|
|
156
|
+
for (const l of listeners) {
|
|
157
|
+
l(state);
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private getCacheKey(procedureName: string, inputs: any) {
|
|
163
|
+
return `${procedureName}-${JSON.stringify(inputs)}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private addCacheListener(key: string, l: Listener) {
|
|
167
|
+
const current = this.listenes.get(key) ?? [];
|
|
168
|
+
current.push(l);
|
|
169
|
+
this.listenes.set(key, current);
|
|
170
|
+
}
|
|
171
|
+
private removeCacheListener(listener: Listener) {
|
|
172
|
+
for (const [k, v] of this.listenes) {
|
|
173
|
+
if (v.find(l => l === listener) !== undefined) {
|
|
174
|
+
const n = v.filter(l => l !== listener);
|
|
175
|
+
this.listenes.set(k, n);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
useCachedQuery<Q extends QueryKey<P>>(procedureName: Q, inputs: InferProcedureInputs<P[Q]>): AsyncHook<InferProcedureOutputs<P[Q]>> {
|
|
182
|
+
const k = this.getCacheKey(String(procedureName), inputs);
|
|
183
|
+
const [state, setState] = useState<AsyncHook<InferProcedureOutputs<P[Q]>>>(this.cache.has(k) ? this.cache.get(k)! : {
|
|
184
|
+
loading: true,
|
|
185
|
+
data: null,
|
|
186
|
+
error: null,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
// we have to refetch the key in case inputs changed
|
|
191
|
+
const newKey = this.getCacheKey(String(procedureName), inputs);
|
|
192
|
+
|
|
193
|
+
if (!this.cache.has(k)) {
|
|
194
|
+
this.createCachedQuery(procedureName, inputs);
|
|
195
|
+
}
|
|
196
|
+
const l: Listener = (s) => setState(s);
|
|
197
|
+
|
|
198
|
+
this.addCacheListener(newKey, l);
|
|
199
|
+
|
|
200
|
+
return () => this.removeCacheListener(l);
|
|
201
|
+
|
|
202
|
+
}, [setState, inputs]);
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
return state;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@covenant-rpc/react",
|
|
3
|
+
"module": "index.ts",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"version": "0.1.3",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@types/bun": "latest"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"typescript": "^5"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"react": "^19.1.1",
|
|
17
|
+
"@covenant-rpc/core": "workspace:*",
|
|
18
|
+
"@covenant-rpc/client": "workspace:*"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|