diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 22b4016be..2d30441d9 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -50,7 +50,7 @@ module.exports = { "no-use-before-define": OFF, "@typescript-eslint/no-non-null-assertion": OFF, "@typescript-eslint/no-explicit-any": OFF, - "@typescript-eslint/no-unused-vars": [ERROR, {"varsIgnorePattern": "^_"}], + "@typescript-eslint/no-unused-vars": [ERROR, { varsIgnorePattern: "^_" }], }, root: true, } diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..181f42c86 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +dist +pnpm-lock.yaml \ No newline at end of file diff --git a/README.md b/README.md index 4ae24e5a7..ac3ee9929 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,6 @@ This is a monorepo containing the following packages: Likely only useful for experimentation, but allows simple (inefficient) tab-to-tab data synchronization -Please note that a reference sync-server peer which demonstrates the use of +Please note that a reference sync-server peer which demonstrates the use of [automerge-repo-network-websocket](/packages/automerge-repo-network-websocket/) is available at [automerge-repo-sync-server](https://github.com/automerge/automerge-repo-sync-server) (this is different from [sync-server](/examples/sync-server)). diff --git a/examples/react-counter/src/App.tsx b/examples/react-counter/src/App.tsx index f569236f8..87e2d881d 100644 --- a/examples/react-counter/src/App.tsx +++ b/examples/react-counter/src/App.tsx @@ -1,7 +1,5 @@ import { AutomergeUrl } from "@automerge/automerge-repo" -import { - useDocument, -} from "@automerge/automerge-repo-react-hooks" +import { useDocument } from "@automerge/automerge-repo-react-hooks" interface Doc { count: number diff --git a/examples/react-todo/src/App.tsx b/examples/react-todo/src/App.tsx index 7659d5018..3e197de08 100644 --- a/examples/react-todo/src/App.tsx +++ b/examples/react-todo/src/App.tsx @@ -1,15 +1,12 @@ import { AutomergeUrl } from "@automerge/automerge-repo" -import { - useDocument, - useRepo, -} from "@automerge/automerge-repo-react-hooks" +import { useDocument, useRepo } from "@automerge/automerge-repo-react-hooks" import cx from "classnames" import { useRef, useState } from "react" import { Todo } from "./Todo.js" import { ExtendedArray, Filter, State, TodoData } from "./types.js" -export function App({url}: {url: AutomergeUrl}) { +export function App({ url }: { url: AutomergeUrl }) { const [state, changeState] = useDocument(url) const newTodoInput = useRef(null) diff --git a/examples/react-todo/src/main.tsx b/examples/react-todo/src/main.tsx index b6f133d7a..5ea4967ef 100644 --- a/examples/react-todo/src/main.tsx +++ b/examples/react-todo/src/main.tsx @@ -38,7 +38,7 @@ window.repo = repo ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + ) diff --git a/examples/react-use-awareness/vite.config.js b/examples/react-use-awareness/vite.config.js index 38250a550..a6e2f225c 100644 --- a/examples/react-use-awareness/vite.config.js +++ b/examples/react-use-awareness/vite.config.js @@ -1,18 +1,11 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react-swc' -import wasm from 'vite-plugin-wasm' -import topLevelAwait from 'vite-plugin-top-level-await' +import { defineConfig } from "vite" +import react from "@vitejs/plugin-react-swc" +import wasm from "vite-plugin-wasm" +import topLevelAwait from "vite-plugin-top-level-await" export default defineConfig({ - plugins: [ - react(), - wasm(), - topLevelAwait() - ], + plugins: [react(), wasm(), topLevelAwait()], worker: { - plugins: () => [ - wasm(), - topLevelAwait() - ] - } -}); + plugins: () => [wasm(), topLevelAwait()], + }, +}) diff --git a/packages/automerge-repo-network-websocket/README.md b/packages/automerge-repo-network-websocket/README.md index 49ccd006c..7e94a5be4 100644 --- a/packages/automerge-repo-network-websocket/README.md +++ b/packages/automerge-repo-network-websocket/README.md @@ -25,38 +25,37 @@ Before sync can continue each peer needs to exchange peer IDs and agree on the protocol version they are using (although currently there is only one version). Handshake is the following steps: -* Once a connection is established the initiating peer sends a +- Once a connection is established the initiating peer sends a [join](#join) message with the `senderId` set to the initiating peers ID and the `protocolVersion` set to "1" -* The receiving peer waits until it receives a message from the initiating +- The receiving peer waits until it receives a message from the initiating peer, if the initiating peer receives a message before sending the join message the initiating peer SHOULD terminate the connection. -* When the receiving peer receives the join message - * if the `protocolVersion` is not "1" the receiving peer sends an - [error](#error) message and terminates the connection - * otherwise - * store the `senderId` as the peer ID of the initiating peer - * emit a "peer-candidate" event with the sender ID as the peer - * respond with a [peer](#peer) message with the `targetId` set to the - initiating peers peer ID, the `senderId` set to the receiving peers - peer ID and the `selectedProtocolVersion` set to "1" - * begin the sync phase -* Once the initiating peer has sent the join message it waits for a peer message +- When the receiving peer receives the join message + - if the `protocolVersion` is not "1" the receiving peer sends an + [error](#error) message and terminates the connection + - otherwise + - store the `senderId` as the peer ID of the initiating peer + - emit a "peer-candidate" event with the sender ID as the peer + - respond with a [peer](#peer) message with the `targetId` set to the + initiating peers peer ID, the `senderId` set to the receiving peers + peer ID and the `selectedProtocolVersion` set to "1" + - begin the sync phase +- Once the initiating peer has sent the join message it waits for a peer message in response. If it receives any other message type before the join message the receiving peer should send an [error](#error) message and terminates the connection -* When the initiating peer receives the peer message - * it stores the `senderId` as the peer ID of the receiving peer. - * it emits a "peer-candidate" event with the sender ID as the peer - * if the `selectedProtocolVersion` is anything other than "1" the initiating +- When the initiating peer receives the peer message + - it stores the `senderId` as the peer ID of the receiving peer. + - it emits a "peer-candidate" event with the sender ID as the peer + - if the `selectedProtocolVersion` is anything other than "1" the initiating peer sends an [error](#error) message and terminates the connection - * it begins the sync phase + - it begins the sync phase #### Peer IDs and storage IDs The peer ID is an ephemeral ID which is assumed to only live for the lifetime of the process which advertises the given ID (e.g. a browser tab). Peers may optionally advertise a storage ID in the `join` and `peer` messages, this is an ID which is assumed to be tied to a persistent storage of some kind (e.g. an IndexedDB in a browser). Many peer IDs can advertise the same storage ID (as in the case of many browser tabs). The use of a storage ID allows other peers to know whether to save and reload sync states for a given peer (if the peer advertises a storage ID, then save and reload the sync state attached to that storage ID). - ### Sync Phase In the sync phase either side may send a [request](#request), [sync](#sync), @@ -71,13 +70,13 @@ from the `NetworkAdapter` on receipt. In some cases peers wish to know about the state of peers who are separated from them by several intermediate peers. For example, a tab running a text editor may wish to show whether the contents of the editor are up to date with respect to a tab running in a browser on another users device. This is achieved by gossiping remote heads across intermediate nodes. The logic for this is the following: -* For a given connection each peer maintains a list of the storage IDs the remote peer is interested in (note this is storage IDs, not peer IDs) -* Any peer can send a [`remote-subscription-changed`](#remote-subscription-changed) message to change the set of storage IDs they want the recipient to watch on the sender's behalf -* Any time a peer receives a sync message it checks: - * Is the sync message from a peer with a storage ID which some other remote peer has registered interest in - * Is the remote peer permitted access to the document which the message pertains to (i.e. either the `sharePolicy` return `true` or the local peer is already syncing the document with the remote) -* The local peer sends a [`remote-heads-changed`](#remote-heads-changed) message to each remote peer who passes these checks -* Additionally, whenever the local peer receives a `remote-heads-changed` message it performs the same checks and additionally checks if the timestamp on the `remote-heads-changed` message is greater than the last timestamp for the same storage ID/document combination and if so it forwards it. +- For a given connection each peer maintains a list of the storage IDs the remote peer is interested in (note this is storage IDs, not peer IDs) +- Any peer can send a [`remote-subscription-changed`](#remote-subscription-changed) message to change the set of storage IDs they want the recipient to watch on the sender's behalf +- Any time a peer receives a sync message it checks: + - Is the sync message from a peer with a storage ID which some other remote peer has registered interest in + - Is the remote peer permitted access to the document which the message pertains to (i.e. either the `sharePolicy` return `true` or the local peer is already syncing the document with the remote) +- The local peer sends a [`remote-heads-changed`](#remote-heads-changed) message to each remote peer who passes these checks +- Additionally, whenever the local peer receives a `remote-heads-changed` message it performs the same checks and additionally checks if the timestamp on the `remote-heads-changed` message is greater than the last timestamp for the same storage ID/document combination and if so it forwards it. In the `browser <-> sync server <-> browser` text editor example above each browser tab would send a `remote-subscription-changed` message to the sync server adding the other browsers storage ID (presumably communicated out of band) to their subscriptions with the sync server. The sync server will then send `remote-heads-changed` messages to each tab when their heads change. @@ -205,7 +204,6 @@ it Sent when a peer wants to send an ephemeral message to another peer - ```cddl { type: "ephemeral", diff --git a/packages/automerge-repo-network-websocket/src/index.ts b/packages/automerge-repo-network-websocket/src/index.ts index c9ffee994..cb3177274 100644 --- a/packages/automerge-repo-network-websocket/src/index.ts +++ b/packages/automerge-repo-network-websocket/src/index.ts @@ -1,9 +1,9 @@ /** * A `NetworkAdapter` which connects to a remote host via WebSockets * - * The websocket protocol requires a server to be listening and a client to + * The websocket protocol requires a server to be listening and a client to * connect to the server. To that end the {@link NodeWSServerAdapter} does not - * make outbound connections and instead listens on the provided socket for + * make outbound connections and instead listens on the provided socket for * new connections whilst the {@link BrowserWebSocketClientAdapter} makes an * outbound connection to the provided socket. * @@ -14,5 +14,12 @@ * */ export { BrowserWebSocketClientAdapter } from "./BrowserWebSocketClientAdapter.js" export { NodeWSServerAdapter } from "./NodeWSServerAdapter.js" -export type { FromClientMessage, FromServerMessage, JoinMessage, LeaveMessage, ErrorMessage, PeerMessage } from "./messages.js" +export type { + FromClientMessage, + FromServerMessage, + JoinMessage, + LeaveMessage, + ErrorMessage, + PeerMessage, +} from "./messages.js" export type { ProtocolVersion, ProtocolV1 } from "./protocolVersion.js" diff --git a/packages/automerge-repo-react-hooks/README.md b/packages/automerge-repo-react-hooks/README.md index ad325e850..7f809a67f 100644 --- a/packages/automerge-repo-react-hooks/README.md +++ b/packages/automerge-repo-react-hooks/README.md @@ -3,24 +3,29 @@ These hooks are provided as helpers for using Automerge in your React project. #### [useBootstrap](./src/useBootstrap.ts) + This hook is used to load a document based on the URL hash, for example `//myapp/#documentId=[document ID]`. It can also load the document ID from localStorage, or create a new document if none is specified. #### [useLocalAwareness](./src/useLocalAwareness.ts) & [useRemoteAwareness](./src/useRemoteAwareness.ts) + These hooks implement ephemeral awareness/presence, similar to [Yjs Awareness](https://docs.yjs.dev/getting-started/adding-awareness). -They allow temporary state to be shared, such as cursor positions or peer online/offline status. +They allow temporary state to be shared, such as cursor positions or peer online/offline status. Ephemeral messages are replicated between peers, but not saved to the Automerge doc, and are used for temporary updates that will be discarded. #### [useRepo/RepoContext](./src/useRepo.ts) + Use RepoContext to set up react context for an Automerge repo. Use useRepo to lookup the repo from context. Most hooks depend on RepoContext being available. #### [useDocument](./src/useDocument.ts) + Return a document & updater fn, by ID. #### [useHandle](./src/useHandle.ts) + Return a handle, by ID. ## Example usage @@ -46,9 +51,7 @@ const sharedWorker = new SharedWorker( async function getRepo(): Promise { return await Repo({ - network: [ - new BroadcastChannelNetworkAdapter(), - ], + network: [new BroadcastChannelNetworkAdapter()], sharePolicy: peerId => peerId.includes("shared-worker"), }) } diff --git a/packages/automerge-repo-react-hooks/src/useDocuments.ts b/packages/automerge-repo-react-hooks/src/useDocuments.ts index 988f6440c..ae1f357ae 100644 --- a/packages/automerge-repo-react-hooks/src/useDocuments.ts +++ b/packages/automerge-repo-react-hooks/src/useDocuments.ts @@ -51,12 +51,18 @@ export const useDocuments = (ids?: DocId[]) => { newIds.forEach(id => { const handle = repo.find(id) // As each document loads, update our map - handle.doc().then(doc => { - updateDocument(id, doc) - addListener(handle) - }).catch(err => { - console.error(`Error loading document ${id} in useDocuments: `, err) - }) + handle + .doc() + .then(doc => { + updateDocument(id, doc) + addListener(handle) + }) + .catch(err => { + console.error( + `Error loading document ${id} in useDocuments: `, + err + ) + }) }) // remove any documents that are no longer in the list diff --git a/packages/automerge-repo-react-hooks/src/useLocalAwareness.ts b/packages/automerge-repo-react-hooks/src/useLocalAwareness.ts index ba1e82c34..a6937f75b 100644 --- a/packages/automerge-repo-react-hooks/src/useLocalAwareness.ts +++ b/packages/automerge-repo-react-hooks/src/useLocalAwareness.ts @@ -1,87 +1,87 @@ -import { useEffect } from "react" -import useStateRef from "react-usestateref" -import { peerEvents } from "./useRemoteAwareness.js" -import { DocHandle } from "@automerge/automerge-repo" - -export interface UseLocalAwarenessProps { - /** The document handle to send ephemeral state on */ - handle: DocHandle - /** Our user ID **/ - userId: string - /** The initial state object/primitive we should advertise */ - initialState: any - /** How frequently to send heartbeats */ - heartbeatTime?: number -} -/** - * This hook maintains state for the local client. - * Like React.useState, it returns a [state, setState] array. - * It is intended to be used alongside useRemoteAwareness. - * - * When state is changed it is broadcast to all clients. - * It also broadcasts a heartbeat to let other clients know it is online. - * - * Note that userIds aren't secure (yet). Any client can lie about theirs. - * - * @param {string} props.userId Unique user ID. Clients can lie about this. - * @param {any} props.initialState Initial state object/primitive - * @param {number?1500} props.heartbeatTime How often to send a heartbeat (in ms) - * @returns [state, setState] - */ -export const useLocalAwareness = ({ - handle, - userId, - initialState, - heartbeatTime = 15000, -}: UseLocalAwarenessProps) => { - const [localState, setLocalState, localStateRef] = useStateRef(initialState) - - const setState = (stateOrUpdater: any) => { - const state = - typeof stateOrUpdater === "function" - ? stateOrUpdater(localStateRef.current) - : stateOrUpdater - setLocalState(state) - // TODO: Send deltas instead of entire state - handle.broadcast([userId, state]) - } - - useEffect(() => { - // Don't broadcast if userId isn't set: this avoids bogus broadcasts - // during the loading of a userId document. - if (!userId) { - return - } - - // Send periodic heartbeats - const heartbeat = () => - void handle.broadcast([userId, localStateRef.current]) - heartbeat() // Initial heartbeat - // TODO: we don't need to send a heartbeat if we've changed state recently; use recursive setTimeout instead of setInterval - const heartbeatIntervalId = setInterval(heartbeat, heartbeatTime) - return () => void clearInterval(heartbeatIntervalId) - }, [handle, userId, heartbeatTime]) - - useEffect(() => { - // Send entire state to new peers - let broadcastTimeoutId: ReturnType - const newPeerEvents = peerEvents.on("new_peer", () => { - broadcastTimeoutId = setTimeout( - () => handle.broadcast([userId, localStateRef.current]), - 500 // Wait for the peer to be ready - ) - }) - return () => { - newPeerEvents.off("new_peer") - broadcastTimeoutId && clearTimeout(broadcastTimeoutId) - } - }, [handle, userId, peerEvents]) - - // TODO: Send an "offline" message on unmount - // useEffect( - // () => () => void handle.broadcast(null), // Same as Yjs awareness - // [] - // ); - - return [localState, setState] -} +import { useEffect } from "react" +import useStateRef from "react-usestateref" +import { peerEvents } from "./useRemoteAwareness.js" +import { DocHandle } from "@automerge/automerge-repo" + +export interface UseLocalAwarenessProps { + /** The document handle to send ephemeral state on */ + handle: DocHandle + /** Our user ID **/ + userId: string + /** The initial state object/primitive we should advertise */ + initialState: any + /** How frequently to send heartbeats */ + heartbeatTime?: number +} +/** + * This hook maintains state for the local client. + * Like React.useState, it returns a [state, setState] array. + * It is intended to be used alongside useRemoteAwareness. + * + * When state is changed it is broadcast to all clients. + * It also broadcasts a heartbeat to let other clients know it is online. + * + * Note that userIds aren't secure (yet). Any client can lie about theirs. + * + * @param {string} props.userId Unique user ID. Clients can lie about this. + * @param {any} props.initialState Initial state object/primitive + * @param {number?1500} props.heartbeatTime How often to send a heartbeat (in ms) + * @returns [state, setState] + */ +export const useLocalAwareness = ({ + handle, + userId, + initialState, + heartbeatTime = 15000, +}: UseLocalAwarenessProps) => { + const [localState, setLocalState, localStateRef] = useStateRef(initialState) + + const setState = (stateOrUpdater: any) => { + const state = + typeof stateOrUpdater === "function" + ? stateOrUpdater(localStateRef.current) + : stateOrUpdater + setLocalState(state) + // TODO: Send deltas instead of entire state + handle.broadcast([userId, state]) + } + + useEffect(() => { + // Don't broadcast if userId isn't set: this avoids bogus broadcasts + // during the loading of a userId document. + if (!userId) { + return + } + + // Send periodic heartbeats + const heartbeat = () => + void handle.broadcast([userId, localStateRef.current]) + heartbeat() // Initial heartbeat + // TODO: we don't need to send a heartbeat if we've changed state recently; use recursive setTimeout instead of setInterval + const heartbeatIntervalId = setInterval(heartbeat, heartbeatTime) + return () => void clearInterval(heartbeatIntervalId) + }, [handle, userId, heartbeatTime]) + + useEffect(() => { + // Send entire state to new peers + let broadcastTimeoutId: ReturnType + const newPeerEvents = peerEvents.on("new_peer", () => { + broadcastTimeoutId = setTimeout( + () => handle.broadcast([userId, localStateRef.current]), + 500 // Wait for the peer to be ready + ) + }) + return () => { + newPeerEvents.off("new_peer") + broadcastTimeoutId && clearTimeout(broadcastTimeoutId) + } + }, [handle, userId, peerEvents]) + + // TODO: Send an "offline" message on unmount + // useEffect( + // () => () => void handle.broadcast(null), // Same as Yjs awareness + // [] + // ); + + return [localState, setState] +} diff --git a/packages/automerge-repo-react-hooks/test/useDocuments.test.tsx b/packages/automerge-repo-react-hooks/test/useDocuments.test.tsx index 36e7bab4e..f3bdc58cd 100644 --- a/packages/automerge-repo-react-hooks/test/useDocuments.test.tsx +++ b/packages/automerge-repo-react-hooks/test/useDocuments.test.tsx @@ -15,7 +15,9 @@ describe("useDocuments", () => { }) const wrapper = ({ children }) => { - return {children} + return ( + {children} + ) } const documentIds = range(10).map(i => { @@ -26,9 +28,12 @@ describe("useDocuments", () => { return { repo, wrapper, documentIds } } - const Component = ({ ids, onDocs }: { - ids: DocumentId[], - onDocs: (documents: Record) => void, + const Component = ({ + ids, + onDocs, + }: { + ids: DocumentId[] + onDocs: (documents: Record) => void }) => { const documents = useDocuments(ids) onDocs(documents) @@ -40,9 +45,11 @@ describe("useDocuments", () => { const onDocs = vi.fn() render(, { wrapper }) - await waitFor(() => expect(onDocs).toHaveBeenCalledWith( - Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }])) - )) + await waitFor(() => + expect(onDocs).toHaveBeenCalledWith( + Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }])) + ) + ) }) it("updates documents when they change", async () => { @@ -50,9 +57,11 @@ describe("useDocuments", () => { const onDocs = vi.fn() render(, { wrapper }) - await waitFor(() => expect(onDocs).toHaveBeenCalledWith( - Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }])) - )) + await waitFor(() => + expect(onDocs).toHaveBeenCalledWith( + Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }])) + ) + ) act(() => { // multiply the value of foo in each document by 10 @@ -61,28 +70,39 @@ describe("useDocuments", () => { handle.change(s => (s.foo *= 10)) }) }) - await waitFor(() => expect(onDocs).toHaveBeenCalledWith( - Object.fromEntries(documentIds.map((id, i) => [id, { foo: i * 10 }])) - )) + await waitFor(() => + expect(onDocs).toHaveBeenCalledWith( + Object.fromEntries(documentIds.map((id, i) => [id, { foo: i * 10 }])) + ) + ) }) it(`removes documents when they're removed from the list of ids`, async () => { const { documentIds, wrapper } = setup() const onDocs = vi.fn() - const { rerender } = render(, { wrapper }) - await waitFor(() => expect(onDocs).toHaveBeenCalledWith( - Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }])) - )) + const { rerender } = render( + , + { wrapper } + ) + await waitFor(() => + expect(onDocs).toHaveBeenCalledWith( + Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }])) + ) + ) // remove the first document rerender() // 👆 Note that this only works because documentIds.slice(1) is a different // object from documentIds. If we modified documentIds directly, the hook // wouldn't re-run. - await waitFor(() => expect(onDocs).toHaveBeenCalledWith( - Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }]).slice(1)) - )) + await waitFor(() => + expect(onDocs).toHaveBeenCalledWith( + Object.fromEntries( + documentIds.map((id, i) => [id, { foo: i }]).slice(1) + ) + ) + ) }) }) diff --git a/packages/automerge-repo-react-hooks/test/useHandle.test.tsx b/packages/automerge-repo-react-hooks/test/useHandle.test.tsx index 910c423c3..ef96fd7d7 100644 --- a/packages/automerge-repo-react-hooks/test/useHandle.test.tsx +++ b/packages/automerge-repo-react-hooks/test/useHandle.test.tsx @@ -1,4 +1,9 @@ -import { AutomergeUrl, DocHandle, PeerId, Repo } from "@automerge/automerge-repo" +import { + AutomergeUrl, + DocHandle, + PeerId, + Repo, +} from "@automerge/automerge-repo" import { DummyStorageAdapter } from "@automerge/automerge-repo/test/helpers/DummyStorageAdapter" import { render, waitFor } from "@testing-library/react" import React from "react" @@ -38,9 +43,12 @@ describe("useHandle", () => { } } - const Component = ({ url, onHandle }: { - url: AutomergeUrl, - onHandle: (handle: DocHandle | undefined) => void, + const Component = ({ + url, + onHandle, + }: { + url: AutomergeUrl + onHandle: (handle: DocHandle | undefined) => void }) => { const handle = useHandle(url) onHandle(handle) @@ -51,7 +59,7 @@ describe("useHandle", () => { const { handleA, wrapper } = setup() const onHandle = vi.fn() - render(, {wrapper}) + render(, { wrapper }) await waitFor(() => expect(onHandle).toHaveBeenLastCalledWith(handleA)) }) @@ -59,7 +67,7 @@ describe("useHandle", () => { const { wrapper } = setup() const onHandle = vi.fn() - render(, {wrapper}) + render(, { wrapper }) await waitFor(() => expect(onHandle).toHaveBeenLastCalledWith(undefined)) }) @@ -67,7 +75,10 @@ describe("useHandle", () => { const { wrapper, handleA, handleB } = setup() const onHandle = vi.fn() - const { rerender } = render(, {wrapper}) + const { rerender } = render( + , + { wrapper } + ) await waitFor(() => expect(onHandle).toHaveBeenLastCalledWith(undefined)) // set url to doc A diff --git a/packages/automerge-repo-react-hooks/test/useRepo.test.tsx b/packages/automerge-repo-react-hooks/test/useRepo.test.tsx index 2269731bb..78f38fede 100644 --- a/packages/automerge-repo-react-hooks/test/useRepo.test.tsx +++ b/packages/automerge-repo-react-hooks/test/useRepo.test.tsx @@ -5,9 +5,7 @@ import { describe, expect, test, vi } from "vitest" import { RepoContext, useRepo } from "../src/useRepo.js" describe("useRepo", () => { - const Component = ({ onRepo }: { - onRepo: (repo: Repo) => void, - }) => { + const Component = ({ onRepo }: { onRepo: (repo: Repo) => void }) => { const repo = useRepo() onRepo(repo) return null @@ -18,7 +16,7 @@ describe("useRepo", () => { // Prevent console spam by swallowing console.error "uncaught error" message const spy = vi.spyOn(console, "error") spy.mockImplementation(() => {}) - expect(() => render( {}}/>)).toThrow( + expect(() => render( {}} />)).toThrow( /Repo was not found on RepoContext/ ) spy.mockRestore() diff --git a/packages/automerge-repo-react-hooks/tsconfig.json b/packages/automerge-repo-react-hooks/tsconfig.json index 48a78e5bb..18d3db25a 100644 --- a/packages/automerge-repo-react-hooks/tsconfig.json +++ b/packages/automerge-repo-react-hooks/tsconfig.json @@ -12,7 +12,5 @@ "strict": true, "skipLibCheck": true }, - "include": [ - "src/**/*.ts" - ] -} \ No newline at end of file + "include": ["src/**/*.ts"] +} diff --git a/packages/automerge-repo-react-hooks/vite.config.ts b/packages/automerge-repo-react-hooks/vite.config.ts index 5e4b15efc..8e4d74e32 100644 --- a/packages/automerge-repo-react-hooks/vite.config.ts +++ b/packages/automerge-repo-react-hooks/vite.config.ts @@ -18,22 +18,22 @@ export default defineConfig({ ], build: { lib: { - entry: resolve(__dirname, 'src/index.ts'), - formats: ['es'], - fileName: 'index', + entry: resolve(__dirname, "src/index.ts"), + formats: ["es"], + fileName: "index", }, rollupOptions: { - external: ['react', 'react/jsx-runtime', 'react-dom', 'tailwindcss'], + external: ["react", "react/jsx-runtime", "react-dom", "tailwindcss"], output: { globals: { - react: 'React', - 'react/jsx-runtime': 'react/jsx-runtime', - 'react-dom': 'ReactDOM', - } - } + react: "React", + "react/jsx-runtime": "react/jsx-runtime", + "react-dom": "ReactDOM", + }, + }, }, }, worker: { plugins: [wasm(), topLevelAwait()], - } + }, }) diff --git a/packages/automerge-repo-svelte-store/README.md b/packages/automerge-repo-svelte-store/README.md index d4461a9a5..86840aa8b 100644 --- a/packages/automerge-repo-svelte-store/README.md +++ b/packages/automerge-repo-svelte-store/README.md @@ -49,7 +49,6 @@ For a working example, see the [Svelte counter demo](../automerge-repo-demo-coun ``` - - ## Contributors + Originally written by Dylan MacKenzie ([@ecstatic-morse](https://github.com/ecstatic-morse)). diff --git a/packages/automerge-repo/src/DocHandle.ts b/packages/automerge-repo/src/DocHandle.ts index 84cadfa4d..5de15e62b 100644 --- a/packages/automerge-repo/src/DocHandle.ts +++ b/packages/automerge-repo/src/DocHandle.ts @@ -244,12 +244,14 @@ export class DocHandle // /** Returns a promise that resolves when the docHandle is in one of the given states */ #statePromise(awaitStates: HandleState | HandleState[]) { - const awaitStatesArray = Array.isArray(awaitStates) ? awaitStates : [awaitStates] + const awaitStatesArray = Array.isArray(awaitStates) + ? awaitStates + : [awaitStates] return waitFor( this.#machine, - s => awaitStatesArray.some((state) => s.matches(state)), + s => awaitStatesArray.some(state => s.matches(state)), // use a longer delay here so as not to race with other delays - {timeout: this.#timeoutDelay * 2} + { timeout: this.#timeoutDelay * 2 } ) } diff --git a/packages/automerge-repo/src/Repo.ts b/packages/automerge-repo/src/Repo.ts index a0db0633b..30970346a 100644 --- a/packages/automerge-repo/src/Repo.ts +++ b/packages/automerge-repo/src/Repo.ts @@ -2,15 +2,18 @@ import { next as Automerge } from "@automerge/automerge" import debug from "debug" import { EventEmitter } from "eventemitter3" import { - generateAutomergeUrl, - interpretAsDocumentId, - parseAutomergeUrl, + generateAutomergeUrl, + interpretAsDocumentId, + parseAutomergeUrl, } from "./AutomergeUrl.js" import { DocHandle, DocHandleEncodedChangePayload } from "./DocHandle.js" import { RemoteHeadsSubscriptions } from "./RemoteHeadsSubscriptions.js" import { headsAreSame } from "./helpers/headsAreSame.js" import { throttle } from "./helpers/throttle.js" -import { NetworkAdapterInterface, type PeerMetadata } from "./network/NetworkAdapterInterface.js" +import { + NetworkAdapterInterface, + type PeerMetadata, +} from "./network/NetworkAdapterInterface.js" import { NetworkSubsystem } from "./network/NetworkSubsystem.js" import { RepoMessage } from "./network/messages.js" import { StorageAdapterInterface } from "./storage/StorageAdapterInterface.js" diff --git a/packages/automerge-repo/src/synchronizer/DocSynchronizer.ts b/packages/automerge-repo/src/synchronizer/DocSynchronizer.ts index 9fa23ba62..1e7c25295 100644 --- a/packages/automerge-repo/src/synchronizer/DocSynchronizer.ts +++ b/packages/automerge-repo/src/synchronizer/DocSynchronizer.ts @@ -228,7 +228,7 @@ export class DocSynchronizer extends Synchronizer { beginSync(peerIds: PeerId[]) { const noPeersWithDocument = peerIds.every( - (peerId) => this.#peerDocumentStatuses[peerId] in ["unavailable", "wants"] + peerId => this.#peerDocumentStatuses[peerId] in ["unavailable", "wants"] ) // At this point if we don't have anything in our storage, we need to use an empty doc to sync diff --git a/packages/automerge-repo/test/helpers/DummyNetworkAdapter.ts b/packages/automerge-repo/test/helpers/DummyNetworkAdapter.ts index 83e685aae..a62516fd0 100644 --- a/packages/automerge-repo/test/helpers/DummyNetworkAdapter.ts +++ b/packages/automerge-repo/test/helpers/DummyNetworkAdapter.ts @@ -1,18 +1,18 @@ -import { pause } from "../../src/helpers/pause.js"; +import { pause } from "../../src/helpers/pause.js" import { Message, NetworkAdapter, PeerId } from "../../src/index.js" export class DummyNetworkAdapter extends NetworkAdapter { #startReady: boolean - #sendMessage?: SendMessageFn; + #sendMessage?: SendMessageFn - constructor(opts: Options = {startReady: true}) { + constructor(opts: Options = { startReady: true }) { super() - this.#startReady = opts.startReady; - this.#sendMessage = opts.sendMessage; + this.#startReady = opts.startReady + this.#sendMessage = opts.sendMessage } connect(peerId: PeerId) { - this.peerId = peerId; + this.peerId = peerId if (this.#startReady) { this.emit("ready", { network: this }) } @@ -21,34 +21,36 @@ export class DummyNetworkAdapter extends NetworkAdapter { disconnect() {} peerCandidate(peerId: PeerId) { - this.emit('peer-candidate', { peerId, peerMetadata: {} }); + this.emit("peer-candidate", { peerId, peerMetadata: {} }) } override send(message: Message) { - this.#sendMessage?.(message); + this.#sendMessage?.(message) } receive(message: Message) { - this.emit('message', message); + this.emit("message", message) } - static createConnectedPair({ latency = 10 }: { latency?: number} = {}) { + static createConnectedPair({ latency = 10 }: { latency?: number } = {}) { const adapter1: DummyNetworkAdapter = new DummyNetworkAdapter({ startReady: true, - sendMessage: (message: Message) => pause(latency).then(() => adapter2.receive(message)), - }); + sendMessage: (message: Message) => + pause(latency).then(() => adapter2.receive(message)), + }) const adapter2: DummyNetworkAdapter = new DummyNetworkAdapter({ startReady: true, - sendMessage: (message: Message) => pause(latency).then(() => adapter1.receive(message)), - }); + sendMessage: (message: Message) => + pause(latency).then(() => adapter1.receive(message)), + }) - return [adapter1, adapter2]; + return [adapter1, adapter2] } } -type SendMessageFn = (message: Message) => void; +type SendMessageFn = (message: Message) => void type Options = { - startReady?: boolean; - sendMessage?: SendMessageFn; + startReady?: boolean + sendMessage?: SendMessageFn } diff --git a/packages/automerge-repo/test/helpers/DummyStorageAdapter.ts b/packages/automerge-repo/test/helpers/DummyStorageAdapter.ts index 3d02178cf..e95923329 100644 --- a/packages/automerge-repo/test/helpers/DummyStorageAdapter.ts +++ b/packages/automerge-repo/test/helpers/DummyStorageAdapter.ts @@ -1,4 +1,8 @@ -import { Chunk, StorageAdapterInterface, type StorageKey } from "../../src/index.js" +import { + Chunk, + StorageAdapterInterface, + type StorageKey, +} from "../../src/index.js" export class DummyStorageAdapter implements StorageAdapterInterface { #data: Record = {} diff --git a/packages/create-repo-node-app/README.md b/packages/create-repo-node-app/README.md index 7126dd07d..8e3c2e384 100644 --- a/packages/create-repo-node-app/README.md +++ b/packages/create-repo-node-app/README.md @@ -17,5 +17,3 @@ yarn create @automerge/repo-node-app ``` Now change into the directory and start editing `index.js` - - diff --git a/packages/create-repo-node-app/src/index.ts b/packages/create-repo-node-app/src/index.ts index 09a328fe8..4660d5989 100644 --- a/packages/create-repo-node-app/src/index.ts +++ b/packages/create-repo-node-app/src/index.ts @@ -1,30 +1,30 @@ #!/usr/bin/env node -import fs from 'fs' -import path from 'path' -import child_process from 'child_process' +import fs from "fs" +import path from "path" +import child_process from "child_process" const execSync = child_process.execSync function createPackageJson(projectName: string) { const packageJson = { name: projectName, - version: '1.0.0', - description: '', - main: 'index.js', - type: 'module', + version: "1.0.0", + description: "", + main: "index.js", + type: "module", scripts: { - start: 'node index.js', + start: "node index.js", }, dependencies: { - '@automerge/automerge-repo': '^1.0', - '@automerge/automerge-repo-network-websocket': '^1.0', - '@automerge/automerge-repo-storage-nodefs': '^1.0', + "@automerge/automerge-repo": "^1.0", + "@automerge/automerge-repo-network-websocket": "^1.0", + "@automerge/automerge-repo-storage-nodefs": "^1.0", }, } fs.writeFileSync( - path.join(projectName, 'package.json'), - JSON.stringify(packageJson, null, 2) + '\n' + path.join(projectName, "package.json"), + JSON.stringify(packageJson, null, 2) + "\n" ) } @@ -39,21 +39,20 @@ const repo = new Repo({ network: [new BrowserWebSocketClientAdapter("wss://sync.automerge.org")] }) ` - fs.writeFileSync(path.join(projectName, 'index.js'), indexJsContent) + fs.writeFileSync(path.join(projectName, "index.js"), indexJsContent) } function main() { const projectName = process.argv[2] if (!projectName) { - console.error('Please provide a project name') + console.error("Please provide a project name") process.exit(1) } fs.mkdirSync(projectName) createPackageJson(projectName) createIndexJs(projectName) - execSync(`cd ${projectName} && npm install`, { stdio: 'inherit' }) + execSync(`cd ${projectName} && npm install`, { stdio: "inherit" }) } main() - diff --git a/packages/create-vite-app/src/index.ts b/packages/create-vite-app/src/index.ts index 17284dfba..8b501baca 100644 --- a/packages/create-vite-app/src/index.ts +++ b/packages/create-vite-app/src/index.ts @@ -41,7 +41,9 @@ function main() { pkg.name = projectName write("package.json", JSON.stringify(pkg, null, 2)) - write(".gitignore", ` + write( + ".gitignore", + ` # Logs logs *.log @@ -66,7 +68,8 @@ dist-ssr *.njsproj *.sln *.sw? - `) + ` + ) execSync(`cd ${projectName} && npm install`, { stdio: "inherit" }) } diff --git a/packages/create-vite-app/template/.github/workflows/deploy.yml b/packages/create-vite-app/template/.github/workflows/deploy.yml index 453b2202a..4b9109f03 100644 --- a/packages/create-vite-app/template/.github/workflows/deploy.yml +++ b/packages/create-vite-app/template/.github/workflows/deploy.yml @@ -4,7 +4,7 @@ name: Deploy static content to Pages on: # Runs on pushes targeting the default branch push: - branches: ['main'] + branches: ["main"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -17,7 +17,7 @@ permissions: # Allow one concurrent deployment concurrency: - group: 'pages' + group: "pages" cancel-in-progress: true jobs: @@ -34,7 +34,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' + cache: "npm" - name: Install dependencies run: yarn - name: Build @@ -45,7 +45,7 @@ jobs: uses: actions/upload-pages-artifact@v3 with: # Upload dist folder - path: './dist' + path: "./dist" - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/packages/create-vite-app/template/index.html b/packages/create-vite-app/template/index.html index b6932173c..4128d7dfa 100644 --- a/packages/create-vite-app/template/index.html +++ b/packages/create-vite-app/template/index.html @@ -1,16 +1,14 @@ - + + + + + + Meet Automerge + - - - - - Meet Automerge - - - -
- - - + +
+ + diff --git a/packages/create-vite-app/template/src/App.tsx b/packages/create-vite-app/template/src/App.tsx index 4ed916d90..cf51131d7 100644 --- a/packages/create-vite-app/template/src/App.tsx +++ b/packages/create-vite-app/template/src/App.tsx @@ -20,12 +20,10 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) {

Meet Automerge

- -

- Open this page in another tab to watch the updates synchronize -

+

Open this page in another tab to watch the updates synchronize

Built with Automerge, Vite, React, and TypeScript diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5edf6cab9..36e2907b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - packages/** - examples/** - - '!packages/create-vite-app/template/**' + - "!packages/create-vite-app/template/**" diff --git a/typedoc.base.json b/typedoc.base.json index b1f5ba4ba..18cb72605 100644 --- a/typedoc.base.json +++ b/typedoc.base.json @@ -1,5 +1,5 @@ { - "$schema": "https://typedoc.org/schema.json", - "includeVersion": true, - "excludePrivate": true + "$schema": "https://typedoc.org/schema.json", + "includeVersion": true, + "excludePrivate": true } diff --git a/typedoc.json b/typedoc.json index d251f454a..a45f35ec1 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,8 +1,8 @@ { - "name": "Automerge Repo", - "entryPoints": ["./packages/*"], - "entryPointStrategy": "packages", - "includeVersion": true, - "exclude": ["examples/*"], - "readme": "./typedoc-readme.md" + "name": "Automerge Repo", + "entryPoints": ["./packages/*"], + "entryPointStrategy": "packages", + "includeVersion": true, + "exclude": ["examples/*"], + "readme": "./typedoc-readme.md" }