@automerge/automerge-repo-react-hooks 0.2.1 → 1.0.0-alpha.2
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 +23 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/useBootstrap.d.ts.map +1 -1
- package/dist/useBootstrap.js +5 -4
- package/dist/useDocument.d.ts +3 -3
- package/dist/useDocument.d.ts.map +1 -1
- package/dist/useDocument.js +6 -6
- package/dist/useHandle.d.ts +2 -2
- package/dist/useHandle.d.ts.map +1 -1
- package/dist/useHandle.js +2 -2
- package/dist/useLocalAwareness.d.ts +24 -0
- package/dist/useLocalAwareness.d.ts.map +1 -0
- package/dist/useLocalAwareness.js +66 -0
- package/dist/useRemoteAwareness.d.ts +22 -0
- package/dist/useRemoteAwareness.d.ts.map +1 -0
- package/dist/useRemoteAwareness.js +76 -0
- package/package.json +20 -3
- package/src/index.ts +2 -0
- package/src/useBootstrap.ts +13 -8
- package/src/useDocument.ts +12 -9
- package/src/useHandle.ts +3 -3
- package/src/useLocalAwareness.ts +81 -0
- package/src/useRemoteAwareness.ts +83 -0
- package/src/useRepo.test.tsx +35 -0
- package/use-awareness-example-project/index.html +11 -0
- package/use-awareness-example-project/package-lock.json +2134 -0
- package/use-awareness-example-project/package.json +27 -0
- package/use-awareness-example-project/src/App.jsx +84 -0
- package/use-awareness-example-project/src/main.jsx +30 -0
- package/use-awareness-example-project/vite.config.js +18 -0
- package/vitest.config.ts +24 -0
package/README.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# React Hooks for Automerge Repo
|
|
2
2
|
|
|
3
|
+
These hooks are provided as helpers for using Automerge in your React project.
|
|
4
|
+
|
|
5
|
+
#### [useBootstrap](./src/useBootstrap.ts)
|
|
6
|
+
This hook is used to load a document based on the URL hash, for example `//myapp/#documentId=[document ID]`.
|
|
7
|
+
It can also load the document ID from localStorage, or create a new document if none is specified.
|
|
8
|
+
|
|
9
|
+
#### [useLocalAwareness](./src/useLocalAwareness.ts) & [useRemoteAwareness](./src/useRemoteAwareness.ts)
|
|
10
|
+
These hooks implement ephemeral awareness/presence, similar to [Yjs Awareness](https://docs.yjs.dev/getting-started/adding-awareness).
|
|
11
|
+
They allow temporary state to be shared, such as cursor positions or peer online/offline status.
|
|
12
|
+
|
|
13
|
+
Ephemeral messages are replicated between peers, but not saved to the Automerge doc, and are used for temporary updates that will be discarded.
|
|
14
|
+
|
|
15
|
+
#### [useRepo/RepoContext](./src/useRepo.ts)
|
|
16
|
+
Use RepoContext to set up react context for an Automerge repo.
|
|
17
|
+
Use useRepo to lookup the repo from context.
|
|
18
|
+
Most hooks depend on RepoContext being available.
|
|
19
|
+
|
|
20
|
+
#### [useDocument](./src/useDocument.ts)
|
|
21
|
+
Return a document & updater fn, by ID.
|
|
22
|
+
|
|
23
|
+
#### [useHandle](./src/useHandle.ts)
|
|
24
|
+
Return a handle, by ID.
|
|
25
|
+
|
|
3
26
|
## Example usage
|
|
4
27
|
|
|
5
28
|
### App Setup
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,6 @@ export { useDocument } from "./useDocument.js";
|
|
|
2
2
|
export { useBootstrap } from './useBootstrap.js';
|
|
3
3
|
export { useHandle } from "./useHandle.js";
|
|
4
4
|
export { RepoContext, useRepo } from "./useRepo.js";
|
|
5
|
+
export { useLocalAwareness } from './useLocalAwareness.js';
|
|
6
|
+
export { useRemoteAwareness } from './useRemoteAwareness.js';
|
|
5
7
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -2,3 +2,5 @@ export { useDocument } from "./useDocument.js";
|
|
|
2
2
|
export { useBootstrap } from './useBootstrap.js';
|
|
3
3
|
export { useHandle } from "./useHandle.js";
|
|
4
4
|
export { RepoContext, useRepo } from "./useRepo.js";
|
|
5
|
+
export { useLocalAwareness } from './useLocalAwareness.js';
|
|
6
|
+
export { useRemoteAwareness } from './useRemoteAwareness.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useBootstrap.d.ts","sourceRoot":"","sources":["../src/useBootstrap.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"useBootstrap.d.ts","sourceRoot":"","sources":["../src/useBootstrap.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,IAAI,EAGL,MAAM,2BAA2B,CAAA;AAKlC,eAAO,MAAM,OAAO,SAAU,MAAM,8BAUnC,CAAA;AAGD,eAAO,MAAM,OAAO,cAQnB,CAAA;AAwBD;;;;;;;;;;;;;;;;GAgBG;AACH,UAAU,mBAAmB,CAAC,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,CAAA;IAC3C,mBAAmB,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,CAAA;CAC7D;AAED,eAAO,MAAM,YAAY,2FAgCxB,CAAA"}
|
package/dist/useBootstrap.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateAutomergeUrl, } from "@automerge/automerge-repo";
|
|
1
2
|
import { useEffect, useState, useMemo } from "react";
|
|
2
3
|
import { useRepo } from "./useRepo.js";
|
|
3
4
|
// Set URL hash
|
|
@@ -42,15 +43,15 @@ export const useBootstrap = ({ key = "documentId", onNoDocument = repo => repo.c
|
|
|
42
43
|
const hash = useHash();
|
|
43
44
|
// Try to get existing document; else create a new one
|
|
44
45
|
const handle = useMemo(() => {
|
|
45
|
-
const
|
|
46
|
+
const documentId = getDocumentId(key, hash);
|
|
46
47
|
try {
|
|
47
|
-
return
|
|
48
|
-
? repo.find(
|
|
48
|
+
return documentId
|
|
49
|
+
? repo.find(generateAutomergeUrl({ documentId }))
|
|
49
50
|
: onNoDocument(repo);
|
|
50
51
|
}
|
|
51
52
|
catch (error) {
|
|
52
53
|
// Presumably the documentId was invalid
|
|
53
|
-
if (
|
|
54
|
+
if (documentId && onInvalidDocumentId)
|
|
54
55
|
return onInvalidDocumentId(repo, error);
|
|
55
56
|
// Forward other errors
|
|
56
57
|
throw error;
|
package/dist/useDocument.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
export declare function useDocument<T>(
|
|
1
|
+
import { ChangeFn, Doc } from "@automerge/automerge";
|
|
2
|
+
import { AutomergeUrl } from "@automerge/automerge-repo";
|
|
3
|
+
export declare function useDocument<T>(documentUrl?: AutomergeUrl): [Doc<T>, (changeFn: ChangeFn<T>) => void];
|
|
4
4
|
//# sourceMappingURL=useDocument.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useDocument.d.ts","sourceRoot":"","sources":["../src/useDocument.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,
|
|
1
|
+
{"version":3,"file":"useDocument.d.ts","sourceRoot":"","sources":["../src/useDocument.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAiB,GAAG,EAAE,MAAM,sBAAsB,CAAA;AACnE,OAAO,EAAE,YAAY,EAA0B,MAAM,2BAA2B,CAAA;AAIhF,wBAAgB,WAAW,CAAC,CAAC,EAAE,WAAW,CAAC,EAAE,YAAY,uBA4BR,SAAS,CAAC,CAAC,KAAK,IAAI,EACpE"}
|
package/dist/useDocument.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { useRepo } from "./useRepo";
|
|
3
|
-
export function useDocument(
|
|
3
|
+
export function useDocument(documentUrl) {
|
|
4
4
|
const [doc, setDoc] = useState();
|
|
5
5
|
const repo = useRepo();
|
|
6
|
-
const handle =
|
|
6
|
+
const handle = documentUrl ? repo.find(documentUrl) : null;
|
|
7
7
|
useEffect(() => {
|
|
8
8
|
if (!handle)
|
|
9
9
|
return;
|
|
10
|
-
handle.
|
|
11
|
-
const
|
|
12
|
-
handle.on("
|
|
10
|
+
handle.doc().then(v => setDoc(v));
|
|
11
|
+
const onChange = (h) => setDoc(h.doc);
|
|
12
|
+
handle.on("change", onChange);
|
|
13
13
|
const cleanup = () => {
|
|
14
|
-
handle.removeListener("
|
|
14
|
+
handle.removeListener("change", onChange);
|
|
15
15
|
};
|
|
16
16
|
return cleanup;
|
|
17
17
|
}, [handle]);
|
package/dist/useHandle.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export declare function useHandle<T>(
|
|
1
|
+
import { AutomergeUrl, DocHandle } from "@automerge/automerge-repo";
|
|
2
|
+
export declare function useHandle<T>(automergeUrl: AutomergeUrl): DocHandle<T>;
|
|
3
3
|
//# sourceMappingURL=useHandle.d.ts.map
|
package/dist/useHandle.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useHandle.d.ts","sourceRoot":"","sources":["../src/useHandle.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"useHandle.d.ts","sourceRoot":"","sources":["../src/useHandle.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAA;AAInE,wBAAgB,SAAS,CAAC,CAAC,EAAE,YAAY,EAAE,YAAY,GAAG,SAAS,CAAC,CAAC,CAAC,CAIrE"}
|
package/dist/useHandle.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useRepo } from "./useRepo.js";
|
|
3
|
-
export function useHandle(
|
|
3
|
+
export function useHandle(automergeUrl) {
|
|
4
4
|
const repo = useRepo();
|
|
5
|
-
const [handle] = useState(repo.find(
|
|
5
|
+
const [handle] = useState(repo.find(automergeUrl));
|
|
6
6
|
return handle;
|
|
7
7
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This hook maintains state for the local client.
|
|
3
|
+
* Like React.useState, it returns a [state, setState] array.
|
|
4
|
+
* It is intended to be used alongside useRemoteAwareness.
|
|
5
|
+
*
|
|
6
|
+
* When state is changed it is broadcast to all clients.
|
|
7
|
+
* It also broadcasts a heartbeat to let other clients know it is online.
|
|
8
|
+
*
|
|
9
|
+
* Note that userIds aren't secure (yet). Any client can lie about theirs.
|
|
10
|
+
* ChannelID is usually just your documentID with some extra characters.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} props.userId Unique user ID. Clients can lie about this.
|
|
13
|
+
* @param {string} props.channelId Which channel to send messages on. This *must* be unique.
|
|
14
|
+
* @param {any} props.initialState Initial state object/primitive
|
|
15
|
+
* @param {number?1500} props.heartbeatTime How often to send a heartbeat (in ms)
|
|
16
|
+
* @returns [state, setState]
|
|
17
|
+
*/
|
|
18
|
+
export declare const useLocalAwareness: ({ userId, channelId: channelIdUnprefixed, initialState, heartbeatTime }?: {
|
|
19
|
+
userId: any;
|
|
20
|
+
channelId: any;
|
|
21
|
+
initialState: any;
|
|
22
|
+
heartbeatTime?: number;
|
|
23
|
+
}) => any[];
|
|
24
|
+
//# sourceMappingURL=useLocalAwareness.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useLocalAwareness.d.ts","sourceRoot":"","sources":["../src/useLocalAwareness.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,iBAAiB;;;;;WAyD7B,CAAC"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { useRepo } from "./useRepo";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import useStateRef from "react-usestateref";
|
|
5
|
+
import { peerEvents, CHANNEL_ID_PREFIX } from "./useRemoteAwareness";
|
|
6
|
+
/**
|
|
7
|
+
* This hook maintains state for the local client.
|
|
8
|
+
* Like React.useState, it returns a [state, setState] array.
|
|
9
|
+
* It is intended to be used alongside useRemoteAwareness.
|
|
10
|
+
*
|
|
11
|
+
* When state is changed it is broadcast to all clients.
|
|
12
|
+
* It also broadcasts a heartbeat to let other clients know it is online.
|
|
13
|
+
*
|
|
14
|
+
* Note that userIds aren't secure (yet). Any client can lie about theirs.
|
|
15
|
+
* ChannelID is usually just your documentID with some extra characters.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} props.userId Unique user ID. Clients can lie about this.
|
|
18
|
+
* @param {string} props.channelId Which channel to send messages on. This *must* be unique.
|
|
19
|
+
* @param {any} props.initialState Initial state object/primitive
|
|
20
|
+
* @param {number?1500} props.heartbeatTime How often to send a heartbeat (in ms)
|
|
21
|
+
* @returns [state, setState]
|
|
22
|
+
*/
|
|
23
|
+
export const useLocalAwareness = ({ userId, channelId: channelIdUnprefixed, initialState, heartbeatTime = 15000 } = {}) => {
|
|
24
|
+
const channelId = CHANNEL_ID_PREFIX + channelIdUnprefixed;
|
|
25
|
+
const [localState, setLocalState, localStateRef] = useStateRef(initialState);
|
|
26
|
+
const { ephemeralData } = useRepo();
|
|
27
|
+
const setState = (stateOrUpdater) => {
|
|
28
|
+
const state = typeof stateOrUpdater === "function"
|
|
29
|
+
? stateOrUpdater(localStateRef.current)
|
|
30
|
+
: stateOrUpdater;
|
|
31
|
+
setLocalState(state);
|
|
32
|
+
// TODO: Send deltas instead of entire state
|
|
33
|
+
ephemeralData.broadcast(channelId, [userId, state]);
|
|
34
|
+
};
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
// Send periodic heartbeats
|
|
37
|
+
const heartbeat = () => void ephemeralData.broadcast(channelId, [userId, localStateRef.current]);
|
|
38
|
+
heartbeat(); // Initial heartbeat
|
|
39
|
+
// TODO: we don't need to send a heartbeat if we've changed state recently; use recursive setTimeout instead of setInterval
|
|
40
|
+
const heartbeatIntervalId = setInterval(heartbeat, heartbeatTime);
|
|
41
|
+
return () => void clearInterval(heartbeatIntervalId);
|
|
42
|
+
}, [userId, channelId, heartbeatTime, ephemeralData]);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
// Send entire state to new peers
|
|
45
|
+
let broadcastTimeoutId;
|
|
46
|
+
const newPeerEvents = peerEvents.on("new_peer", (e) => {
|
|
47
|
+
if (e.channelId !== channelId)
|
|
48
|
+
return;
|
|
49
|
+
broadcastTimeoutId = setTimeout(() => void ephemeralData.broadcast(channelId, [
|
|
50
|
+
userId,
|
|
51
|
+
localStateRef.current,
|
|
52
|
+
]), 500 // Wait for the peer to be ready
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
newPeerEvents.off();
|
|
57
|
+
broadcastTimeoutId && clearTimeout(broadcastTimeoutId);
|
|
58
|
+
};
|
|
59
|
+
}, [userId, channelId, peerEvents]);
|
|
60
|
+
// TODO: Send an "offline" message on unmount
|
|
61
|
+
// useEffect(
|
|
62
|
+
// () => () => void ephemeralData.broadcast(channelId, null), // Same as Yjs awareness
|
|
63
|
+
// []
|
|
64
|
+
// );
|
|
65
|
+
return [localState, setState];
|
|
66
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import EventEmitter from "eventemitter3";
|
|
2
|
+
export declare const CHANNEL_ID_PREFIX = "aw\n";
|
|
3
|
+
export declare const peerEvents: EventEmitter<string | symbol, any>;
|
|
4
|
+
/**
|
|
5
|
+
*
|
|
6
|
+
* This hook returns read-only state for remote clients.
|
|
7
|
+
* It also returns their heartbeat status.
|
|
8
|
+
* It is intended to be used alongside useLocalAwareness.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} props.channelId Which channel to send messages on. This *must* be unique.
|
|
11
|
+
* @param {string?} props.localUserId Automerge BroadcastChannel sometimes sends us our own messages; optionally filters them
|
|
12
|
+
* @param {number?30000} props.offlineTimeout How long to wait (in ms) before marking a peer as offline
|
|
13
|
+
* @param {function?} props.getTime Function to provide current epoch time (used for testing)
|
|
14
|
+
* @returns [ peerStates: { [userId]: state, ... }, { [userId]: heartbeatEpochTime, ...} ]
|
|
15
|
+
*/
|
|
16
|
+
export declare const useRemoteAwareness: ({ channelId: channelIdUnprefixed, localUserId, offlineTimeout, getTime, }?: {
|
|
17
|
+
channelId: any;
|
|
18
|
+
localUserId: any;
|
|
19
|
+
offlineTimeout?: number;
|
|
20
|
+
getTime?: () => number;
|
|
21
|
+
}) => {}[];
|
|
22
|
+
//# sourceMappingURL=useRemoteAwareness.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useRemoteAwareness.d.ts","sourceRoot":"","sources":["../src/useRemoteAwareness.ts"],"names":[],"mappings":"AAIA,OAAO,YAAY,MAAM,eAAe,CAAC;AAGzC,eAAO,MAAM,iBAAiB,SAAS,CAAC;AAGxC,eAAO,MAAM,UAAU,oCAAqB,CAAC;AAE7C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB;;;;;UA0D9B,CAAC"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { useRepo } from "./useRepo";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import useStateRef from "react-usestateref";
|
|
5
|
+
import EventEmitter from "eventemitter3";
|
|
6
|
+
// Add this to the beginning of every channel ID to help prevent collisions
|
|
7
|
+
export const CHANNEL_ID_PREFIX = "aw\n";
|
|
8
|
+
// Emits new_peer event when a new peer is seen
|
|
9
|
+
export const peerEvents = new EventEmitter();
|
|
10
|
+
/**
|
|
11
|
+
*
|
|
12
|
+
* This hook returns read-only state for remote clients.
|
|
13
|
+
* It also returns their heartbeat status.
|
|
14
|
+
* It is intended to be used alongside useLocalAwareness.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} props.channelId Which channel to send messages on. This *must* be unique.
|
|
17
|
+
* @param {string?} props.localUserId Automerge BroadcastChannel sometimes sends us our own messages; optionally filters them
|
|
18
|
+
* @param {number?30000} props.offlineTimeout How long to wait (in ms) before marking a peer as offline
|
|
19
|
+
* @param {function?} props.getTime Function to provide current epoch time (used for testing)
|
|
20
|
+
* @returns [ peerStates: { [userId]: state, ... }, { [userId]: heartbeatEpochTime, ...} ]
|
|
21
|
+
*/
|
|
22
|
+
export const useRemoteAwareness = ({ channelId: channelIdUnprefixed, localUserId, offlineTimeout = 30000, getTime = () => new Date().getTime(), } = {}) => {
|
|
23
|
+
// TODO: You should be able to use multiple instances of this hook on the same channelID (write test)
|
|
24
|
+
// TODO: This should support some kind of caching or memoization when switching between channelIDs
|
|
25
|
+
const channelId = CHANNEL_ID_PREFIX + channelIdUnprefixed;
|
|
26
|
+
const [peerStates, setPeerStates, peerStatesRef] = useStateRef({});
|
|
27
|
+
const [heartbeats, setHeartbeats, heartbeatsRef] = useStateRef({});
|
|
28
|
+
const { ephemeralData } = useRepo(); // EphemeralData API lets us send messages to peers on the automerge document
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
// Receive incoming message
|
|
31
|
+
const handleIncomingUpdate = (event) => {
|
|
32
|
+
try {
|
|
33
|
+
if (event.channelId !== channelId)
|
|
34
|
+
return;
|
|
35
|
+
const [userId, state] = event.data;
|
|
36
|
+
if (userId === localUserId)
|
|
37
|
+
return;
|
|
38
|
+
if (!heartbeatsRef.current[userId])
|
|
39
|
+
peerEvents.emit("new_peer", event); // Let useLocalAwareness know we've seen a new peer
|
|
40
|
+
setPeerStates({
|
|
41
|
+
...peerStatesRef.current,
|
|
42
|
+
[userId]: state,
|
|
43
|
+
});
|
|
44
|
+
setHeartbeats({
|
|
45
|
+
...heartbeatsRef.current,
|
|
46
|
+
[userId]: getTime(),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
// Remove peers we haven't seen recently
|
|
54
|
+
const pruneOfflinePeers = () => {
|
|
55
|
+
const peerStates = peerStatesRef.current;
|
|
56
|
+
const heartbeats = heartbeatsRef.current;
|
|
57
|
+
const time = getTime();
|
|
58
|
+
for (const key in heartbeats) {
|
|
59
|
+
if (time - heartbeats[key] > offlineTimeout) {
|
|
60
|
+
delete peerStates[key];
|
|
61
|
+
delete heartbeats[key];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
setPeerStates(peerStates);
|
|
65
|
+
setHeartbeats(heartbeats);
|
|
66
|
+
};
|
|
67
|
+
ephemeralData.on("data", handleIncomingUpdate);
|
|
68
|
+
// Check for offline peers every `offlineTimeout` ms
|
|
69
|
+
const pruneOfflinePeersIntervalId = setInterval(pruneOfflinePeers, offlineTimeout);
|
|
70
|
+
return () => {
|
|
71
|
+
ephemeralData.removeListener("data", handleIncomingUpdate);
|
|
72
|
+
clearInterval(pruneOfflinePeersIntervalId);
|
|
73
|
+
};
|
|
74
|
+
}, [channelId, localUserId, offlineTimeout, getTime, ephemeralData]);
|
|
75
|
+
return [peerStates, heartbeats];
|
|
76
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automerge/automerge-repo-react-hooks",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "1.0.0-alpha.2",
|
|
4
4
|
"description": "Hooks to access an Automerge Repo from your react app.",
|
|
5
5
|
"repository": "https://github.com/automerge/automerge-repo",
|
|
6
6
|
"author": "Peter van Hardenberg <pvh@pvh.ca>",
|
|
@@ -10,10 +10,27 @@
|
|
|
10
10
|
"main": "dist/index.js",
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
|
+
"test": "vitest run",
|
|
13
14
|
"watch": "npm-watch"
|
|
14
15
|
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@automerge/automerge": "^2.1.0-alpha.10"
|
|
18
|
+
},
|
|
15
19
|
"dependencies": {
|
|
16
|
-
"@automerge/automerge-repo": "^0.2
|
|
20
|
+
"@automerge/automerge-repo": "^1.0.0-alpha.2",
|
|
21
|
+
"eventemitter3": "^5.0.0",
|
|
22
|
+
"react": "^18.2.0",
|
|
23
|
+
"react-usestateref": "^1.0.8"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@testing-library/react": "^14.0.0",
|
|
27
|
+
"@vitejs/plugin-react": "^4.0.2",
|
|
28
|
+
"jsdom": "^22.1.0",
|
|
29
|
+
"react": "^18.2.0",
|
|
30
|
+
"react-dom": "^18.2.0",
|
|
31
|
+
"vite-plugin-top-level-await": "^1.3.1",
|
|
32
|
+
"vite-plugin-wasm": "^3.2.2",
|
|
33
|
+
"vitest": "^0.33.0"
|
|
17
34
|
},
|
|
18
35
|
"watch": {
|
|
19
36
|
"build": {
|
|
@@ -26,5 +43,5 @@
|
|
|
26
43
|
"publishConfig": {
|
|
27
44
|
"access": "public"
|
|
28
45
|
},
|
|
29
|
-
"gitHead": "
|
|
46
|
+
"gitHead": "b5830dde8f135b694809698aaad2a9fdc79a9898"
|
|
30
47
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { useDocument } from "./useDocument.js"
|
|
|
2
2
|
export { useBootstrap } from './useBootstrap.js'
|
|
3
3
|
export { useHandle } from "./useHandle.js"
|
|
4
4
|
export { RepoContext, useRepo } from "./useRepo.js"
|
|
5
|
+
export { useLocalAwareness } from './useLocalAwareness.js'
|
|
6
|
+
export { useRemoteAwareness } from './useRemoteAwareness.js'
|
package/src/useBootstrap.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DocHandle,
|
|
3
|
+
Repo,
|
|
4
|
+
type DocumentId,
|
|
5
|
+
generateAutomergeUrl,
|
|
6
|
+
} from "@automerge/automerge-repo"
|
|
2
7
|
import { useEffect, useState, useMemo } from "react"
|
|
3
8
|
import { useRepo } from "./useRepo.js"
|
|
4
9
|
|
|
@@ -27,7 +32,7 @@ export const useHash = () => {
|
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
// Get a key from a query-param-style URL hash
|
|
30
|
-
const getQueryParamValue = (key: string, hash) =>
|
|
35
|
+
const getQueryParamValue = (key: string, hash: string) =>
|
|
31
36
|
new URLSearchParams(hash.substr(1)).get(key)
|
|
32
37
|
|
|
33
38
|
const setQueryParamValue = (key: string, value, hash): string => {
|
|
@@ -36,10 +41,10 @@ const setQueryParamValue = (key: string, value, hash): string => {
|
|
|
36
41
|
return u.toString()
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
const getDocumentId = (key, hash) =>
|
|
44
|
+
const getDocumentId = (key: string, hash: string) =>
|
|
40
45
|
key && (getQueryParamValue(key, hash) || localStorage.getItem(key))
|
|
41
46
|
|
|
42
|
-
const setDocumentId = (key, documentId) => {
|
|
47
|
+
const setDocumentId = (key: string, documentId: DocumentId) => {
|
|
43
48
|
if (key) {
|
|
44
49
|
// Only set URL hash if document ID changed
|
|
45
50
|
if (documentId !== getQueryParamValue(key, window.location.hash))
|
|
@@ -81,14 +86,14 @@ export const useBootstrap = <T>({
|
|
|
81
86
|
|
|
82
87
|
// Try to get existing document; else create a new one
|
|
83
88
|
const handle = useMemo((): DocHandle<T> => {
|
|
84
|
-
const
|
|
89
|
+
const documentId = getDocumentId(key, hash) as DocumentId | undefined
|
|
85
90
|
try {
|
|
86
|
-
return
|
|
87
|
-
? repo.find(
|
|
91
|
+
return documentId
|
|
92
|
+
? repo.find(generateAutomergeUrl({ documentId }))
|
|
88
93
|
: onNoDocument(repo)
|
|
89
94
|
} catch (error) {
|
|
90
95
|
// Presumably the documentId was invalid
|
|
91
|
-
if (
|
|
96
|
+
if (documentId && onInvalidDocumentId)
|
|
92
97
|
return onInvalidDocumentId(repo, error)
|
|
93
98
|
// Forward other errors
|
|
94
99
|
throw error
|
package/src/useDocument.ts
CHANGED
|
@@ -1,29 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { ChangeFn, ChangeOptions, Doc } from "@automerge/automerge"
|
|
2
|
+
import { AutomergeUrl, DocHandleChangePayload } from "@automerge/automerge-repo"
|
|
3
3
|
import { useEffect, useState } from "react"
|
|
4
4
|
import { useRepo } from "./useRepo"
|
|
5
5
|
|
|
6
|
-
export function useDocument<T>(
|
|
6
|
+
export function useDocument<T>(documentUrl?: AutomergeUrl) {
|
|
7
7
|
const [doc, setDoc] = useState<Doc<T>>()
|
|
8
8
|
const repo = useRepo()
|
|
9
9
|
|
|
10
|
-
const handle =
|
|
10
|
+
const handle = documentUrl ? repo.find<T>(documentUrl) : null
|
|
11
11
|
|
|
12
12
|
useEffect(() => {
|
|
13
13
|
if (!handle) return
|
|
14
14
|
|
|
15
|
-
handle.
|
|
15
|
+
handle.doc().then(v => setDoc(v))
|
|
16
16
|
|
|
17
|
-
const
|
|
18
|
-
handle.on("
|
|
17
|
+
const onChange = (h: DocHandleChangePayload<T>) => setDoc(h.doc)
|
|
18
|
+
handle.on("change", onChange)
|
|
19
19
|
const cleanup = () => {
|
|
20
|
-
handle.removeListener("
|
|
20
|
+
handle.removeListener("change", onChange)
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
return cleanup
|
|
24
24
|
}, [handle])
|
|
25
25
|
|
|
26
|
-
const changeDoc = (
|
|
26
|
+
const changeDoc = (
|
|
27
|
+
changeFn: ChangeFn<T>,
|
|
28
|
+
options?: ChangeOptions<T> | undefined
|
|
29
|
+
) => {
|
|
27
30
|
if (!handle) return
|
|
28
31
|
handle.change(changeFn, options)
|
|
29
32
|
}
|
package/src/useHandle.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AutomergeUrl, DocHandle } from "@automerge/automerge-repo"
|
|
2
2
|
import { useState } from "react"
|
|
3
3
|
import { useRepo } from "./useRepo.js"
|
|
4
4
|
|
|
5
|
-
export function useHandle<T>(
|
|
5
|
+
export function useHandle<T>(automergeUrl: AutomergeUrl): DocHandle<T> {
|
|
6
6
|
const repo = useRepo()
|
|
7
|
-
const [handle] = useState<DocHandle<T>>(repo.find(
|
|
7
|
+
const [handle] = useState<DocHandle<T>>(repo.find(automergeUrl))
|
|
8
8
|
return handle
|
|
9
9
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { useRepo } from "./useRepo";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import useStateRef from "react-usestateref";
|
|
5
|
+
import { peerEvents, CHANNEL_ID_PREFIX } from "./useRemoteAwareness";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* This hook maintains state for the local client.
|
|
9
|
+
* Like React.useState, it returns a [state, setState] array.
|
|
10
|
+
* It is intended to be used alongside useRemoteAwareness.
|
|
11
|
+
*
|
|
12
|
+
* When state is changed it is broadcast to all clients.
|
|
13
|
+
* It also broadcasts a heartbeat to let other clients know it is online.
|
|
14
|
+
*
|
|
15
|
+
* Note that userIds aren't secure (yet). Any client can lie about theirs.
|
|
16
|
+
* ChannelID is usually just your documentID with some extra characters.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} props.userId Unique user ID. Clients can lie about this.
|
|
19
|
+
* @param {string} props.channelId Which channel to send messages on. This *must* be unique.
|
|
20
|
+
* @param {any} props.initialState Initial state object/primitive
|
|
21
|
+
* @param {number?1500} props.heartbeatTime How often to send a heartbeat (in ms)
|
|
22
|
+
* @returns [state, setState]
|
|
23
|
+
*/
|
|
24
|
+
export const useLocalAwareness = ({
|
|
25
|
+
userId,
|
|
26
|
+
channelId: channelIdUnprefixed,
|
|
27
|
+
initialState,
|
|
28
|
+
heartbeatTime = 15000
|
|
29
|
+
} = {}) => {
|
|
30
|
+
const channelId = CHANNEL_ID_PREFIX + channelIdUnprefixed;
|
|
31
|
+
const [localState, setLocalState, localStateRef] = useStateRef(initialState);
|
|
32
|
+
const { ephemeralData } = useRepo();
|
|
33
|
+
|
|
34
|
+
const setState = (stateOrUpdater) => {
|
|
35
|
+
const state =
|
|
36
|
+
typeof stateOrUpdater === "function"
|
|
37
|
+
? stateOrUpdater(localStateRef.current)
|
|
38
|
+
: stateOrUpdater;
|
|
39
|
+
setLocalState(state);
|
|
40
|
+
// TODO: Send deltas instead of entire state
|
|
41
|
+
ephemeralData.broadcast(channelId, [userId, state]);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
// Send periodic heartbeats
|
|
46
|
+
const heartbeat = () =>
|
|
47
|
+
void ephemeralData.broadcast(channelId, [userId, localStateRef.current]);
|
|
48
|
+
heartbeat(); // Initial heartbeat
|
|
49
|
+
// TODO: we don't need to send a heartbeat if we've changed state recently; use recursive setTimeout instead of setInterval
|
|
50
|
+
const heartbeatIntervalId = setInterval(heartbeat, heartbeatTime);
|
|
51
|
+
return () => void clearInterval(heartbeatIntervalId);
|
|
52
|
+
}, [userId, channelId, heartbeatTime, ephemeralData]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
// Send entire state to new peers
|
|
56
|
+
let broadcastTimeoutId;
|
|
57
|
+
const newPeerEvents = peerEvents.on("new_peer", (e) => {
|
|
58
|
+
if (e.channelId !== channelId) return;
|
|
59
|
+
broadcastTimeoutId = setTimeout(
|
|
60
|
+
() =>
|
|
61
|
+
void ephemeralData.broadcast(channelId, [
|
|
62
|
+
userId,
|
|
63
|
+
localStateRef.current,
|
|
64
|
+
]),
|
|
65
|
+
500 // Wait for the peer to be ready
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
return () => {
|
|
69
|
+
newPeerEvents.off();
|
|
70
|
+
broadcastTimeoutId && clearTimeout(broadcastTimeoutId);
|
|
71
|
+
};
|
|
72
|
+
}, [userId, channelId, peerEvents]);
|
|
73
|
+
|
|
74
|
+
// TODO: Send an "offline" message on unmount
|
|
75
|
+
// useEffect(
|
|
76
|
+
// () => () => void ephemeralData.broadcast(channelId, null), // Same as Yjs awareness
|
|
77
|
+
// []
|
|
78
|
+
// );
|
|
79
|
+
|
|
80
|
+
return [localState, setState];
|
|
81
|
+
};
|