@peterddod/phop 1.0.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,84 @@
1
+ # phop
2
+
3
+ Peer-to-peer state management for React using WebRTC. Share and sync state across browsers in real time — no backend required.
4
+
5
+ > ⚠️ **Early Development** — P2P synchronization features are under active development
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @peterddod/phop
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Wrap your app in a `<Room>` provider and connect to a signaling server:
16
+
17
+ ```tsx
18
+ import { Room, useRoom, useSharedState } from '@peterddod/phop';
19
+
20
+ function App() {
21
+ return (
22
+ <Room signallingServerUrl="wss://your-signalling-server" roomId="my-room">
23
+ <Counter />
24
+ </Room>
25
+ );
26
+ }
27
+
28
+ function Counter() {
29
+ const [count, setCount] = useSharedState('count', 0);
30
+
31
+ return (
32
+ <button onClick={() => setCount(count + 1)}>
33
+ Count: {count}
34
+ </button>
35
+ );
36
+ }
37
+ ```
38
+
39
+ State updates in `useSharedState` are automatically broadcast to all peers in the room and merged using a configurable conflict resolution strategy.
40
+
41
+ ## API
42
+
43
+ ### `<Room>`
44
+
45
+ Establishes a WebRTC mesh with all peers in the given room.
46
+
47
+ | Prop | Type | Description |
48
+ |------|------|-------------|
49
+ | `signallingServerUrl` | `string` | WebSocket URL of the signaling server |
50
+ | `roomId` | `string` | Room identifier — peers sharing a room ID connect to each other |
51
+
52
+ ### `useSharedState(key, initialValue, options?)`
53
+
54
+ Shared state hook — works like `useState` but syncs across all peers in the room.
55
+
56
+ ```ts
57
+ const [value, setValue] = useSharedState<T>(key: string, initialValue: T, options?: {
58
+ mergeStrategy?: MergeStrategy; // default: lastWriteWins
59
+ })
60
+ ```
61
+
62
+ ### `useRoom()`
63
+
64
+ Access room metadata and low-level messaging.
65
+
66
+ ```ts
67
+ const { peerId, peers, isConnected, broadcast, onMessage } = useRoom();
68
+ ```
69
+
70
+ ## Signaling Server
71
+
72
+ phop requires a lightweight signaling server to coordinate the initial WebRTC handshake. Once peers are connected, all state sync happens directly between browsers.
73
+
74
+ A production-ready server is available as a Docker image:
75
+
76
+ ```bash
77
+ docker run -p 8080:8080 ghcr.io/peterddod/phop/signalling-server:latest
78
+ ```
79
+
80
+ Source and self-hosting instructions: [`packages/signalling-server`](../signalling-server)
81
+
82
+ ## License
83
+
84
+ MIT © [Peter Dodd](https://github.com/peterddod)
@@ -0,0 +1,133 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as react from 'react';
3
+
4
+ type JSONSerializable = string | number | boolean | null | undefined | JSONSerializable[] | {
5
+ [key: string]: JSONSerializable;
6
+ };
7
+ type Message<TData extends JSONSerializable = JSONSerializable> = {
8
+ /** The peer ID of the sender */
9
+ senderId: string;
10
+ /** The data of the message */
11
+ data: TData;
12
+ /** The timestamp of when the message was sent */
13
+ timestamp: number;
14
+ };
15
+ type MessageHandler<TData extends JSONSerializable = JSONSerializable> = (message: Message<TData>) => void;
16
+
17
+ interface RoomContextValue {
18
+ roomId: string;
19
+ peerId: string;
20
+ peers: string[];
21
+ isConnected: boolean;
22
+ broadcast: <TData extends JSONSerializable = JSONSerializable>(message: Message<TData>) => void;
23
+ sendToPeer: <TData extends JSONSerializable = JSONSerializable>(peerId: string, message: Message<TData>) => void;
24
+ onMessage: <TData extends JSONSerializable = JSONSerializable>(handler: MessageHandler<TData>) => () => void;
25
+ onPeerConnected: (handler: (remotePeerId: string) => void) => () => void;
26
+ }
27
+ declare const RoomContext: react.Context<RoomContextValue | null>;
28
+ interface RoomProps extends React.PropsWithChildren {
29
+ signallingServerUrl: string;
30
+ roomId: string;
31
+ }
32
+ declare function Room({ children, signallingServerUrl, roomId }: RoomProps): react_jsx_runtime.JSX.Element;
33
+
34
+ type MergeMeta = Record<string, JSONSerializable>;
35
+ interface MergeStrategy<TState extends JSONSerializable = JSONSerializable, TMeta extends MergeMeta = MergeMeta> {
36
+ /** Initial metadata before any sync or update. */
37
+ initialMeta: TMeta;
38
+ /** Produce meta when this peer sets state (e.g. timestamp, version). */
39
+ createMeta(): TMeta;
40
+ /**
41
+ * Decide how to merge incoming state with current.
42
+ * Return new { state, meta } to apply, or null to keep current.
43
+ */
44
+ merge(currentState: TState | null, currentMeta: TMeta, incomingState: TState | null, incomingMeta: TMeta, senderId: string): {
45
+ state: TState | null;
46
+ meta: TMeta;
47
+ } | null;
48
+ }
49
+
50
+ /**
51
+ * Metadata carried by the Lamport clock strategy.
52
+ * `clock` is a monotonically increasing logical counter; `tiebreaker` is the
53
+ * writing peer's ID, used to deterministically resolve equal-clock conflicts.
54
+ */
55
+ type LamportMeta = {
56
+ clock: number;
57
+ tiebreaker: string;
58
+ };
59
+ /**
60
+ * Creates a Lamport logical-clock merge strategy.
61
+ *
62
+ * Unlike wall-clock timestamps, Lamport clocks do not rely on peers having
63
+ * synchronised system clocks. The clock advances monotonically: it increments
64
+ * on every local write and is fast-forwarded to `max(local, incoming)` on
65
+ * every receive, so causal ordering is preserved across all peers.
66
+ *
67
+ * Concurrent writes (same clock value) are broken deterministically by
68
+ * comparing the writing peer's ID as a string, so every peer reaches the same
69
+ * result independently.
70
+ *
71
+ * @param getPeerId - A function that returns the local peer's ID at call time.
72
+ * Pass a ref-backed getter so the strategy stays stable even before the
73
+ * signalling handshake completes.
74
+ */
75
+ declare function createLamportStrategy(getPeerId: () => string): MergeStrategy<JSONSerializable, LamportMeta>;
76
+
77
+ /**
78
+ * Metadata carried by the Last Write Wins strategy.
79
+ * `timestamp` is a wall-clock time in milliseconds; `tiebreaker` is the
80
+ * writing peer's ID, used to deterministically resolve same-millisecond conflicts.
81
+ */
82
+ type LastWriteWinsMeta = {
83
+ timestamp: number;
84
+ tiebreaker: string;
85
+ };
86
+ /**
87
+ * Creates a Last Write Wins merge strategy.
88
+ *
89
+ * Accepts the incoming state whenever its wall-clock timestamp is strictly
90
+ * greater than the current one. Ties (same millisecond) are broken
91
+ * deterministically by comparing peer ID strings, so every peer converges to
92
+ * the same result independently without coordination.
93
+ *
94
+ * Note: this strategy relies on peers having reasonably synchronised system
95
+ * clocks. It is appropriate for use cases where approximate recency is
96
+ * sufficient (e.g. presence indicators, cursor positions, UI state), but
97
+ * should not be used where causal ordering must be guaranteed — prefer
98
+ * `createLamportStrategy` in that case.
99
+ *
100
+ * @param getPeerId - A function that returns the local peer's ID at call time.
101
+ * Pass a ref-backed getter so the strategy stays stable even before the
102
+ * signalling handshake completes.
103
+ */
104
+ declare function createLastWriteWinsStrategy(getPeerId: () => string): MergeStrategy<JSONSerializable, LastWriteWinsMeta>;
105
+
106
+ declare function useRoom(): RoomContextValue;
107
+
108
+ /**
109
+ * A hook that allows you to share state between multiple peers.
110
+ *
111
+ * Works hostlessly by:
112
+ *
113
+ * - Broadcasting updates to all peers under the given key; everyone keeps the
114
+ * causally latest state according to a Lamport logical clock.
115
+ * - Late joiners: when a data channel opens to a new peer, both sides push
116
+ * their current state to each other; the merge strategy keeps the winner.
117
+ *
118
+ * @param key - A string key that namespaces this shared state slice. Multiple calls with the same key share state; different keys are independent.
119
+ * @param initialState - The initial state of the shared state (used only before any sync or update).
120
+ * @returns A tuple containing the current state and a function to update the state.
121
+ */
122
+ declare function useSharedState<TState extends JSONSerializable>(key: string, initialState: TState | null): [state: TState | null, setState: (next: TState | null) => void];
123
+ /**
124
+ * A hook that allows you to share state between multiple peers with a custom merge strategy.
125
+ *
126
+ * @param key - A string key that namespaces this shared state slice.
127
+ * @param initialState - The initial state (used only before any sync or update).
128
+ * @param strategy - A merge strategy controlling how incoming state is reconciled.
129
+ * @returns A tuple containing the current state and a function to update the state.
130
+ */
131
+ declare function useSharedState<TState extends JSONSerializable, TMeta extends MergeMeta>(key: string, initialState: TState | null, strategy: MergeStrategy<TState, TMeta>): [state: TState | null, setState: (next: TState | null) => void];
132
+
133
+ export { type JSONSerializable, type LamportMeta, type LastWriteWinsMeta, type MergeMeta, type MergeStrategy, type Message, type MessageHandler, Room, RoomContext, type RoomContextValue, createLamportStrategy, createLastWriteWinsStrategy, useRoom, useSharedState };
@@ -0,0 +1,133 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as react from 'react';
3
+
4
+ type JSONSerializable = string | number | boolean | null | undefined | JSONSerializable[] | {
5
+ [key: string]: JSONSerializable;
6
+ };
7
+ type Message<TData extends JSONSerializable = JSONSerializable> = {
8
+ /** The peer ID of the sender */
9
+ senderId: string;
10
+ /** The data of the message */
11
+ data: TData;
12
+ /** The timestamp of when the message was sent */
13
+ timestamp: number;
14
+ };
15
+ type MessageHandler<TData extends JSONSerializable = JSONSerializable> = (message: Message<TData>) => void;
16
+
17
+ interface RoomContextValue {
18
+ roomId: string;
19
+ peerId: string;
20
+ peers: string[];
21
+ isConnected: boolean;
22
+ broadcast: <TData extends JSONSerializable = JSONSerializable>(message: Message<TData>) => void;
23
+ sendToPeer: <TData extends JSONSerializable = JSONSerializable>(peerId: string, message: Message<TData>) => void;
24
+ onMessage: <TData extends JSONSerializable = JSONSerializable>(handler: MessageHandler<TData>) => () => void;
25
+ onPeerConnected: (handler: (remotePeerId: string) => void) => () => void;
26
+ }
27
+ declare const RoomContext: react.Context<RoomContextValue | null>;
28
+ interface RoomProps extends React.PropsWithChildren {
29
+ signallingServerUrl: string;
30
+ roomId: string;
31
+ }
32
+ declare function Room({ children, signallingServerUrl, roomId }: RoomProps): react_jsx_runtime.JSX.Element;
33
+
34
+ type MergeMeta = Record<string, JSONSerializable>;
35
+ interface MergeStrategy<TState extends JSONSerializable = JSONSerializable, TMeta extends MergeMeta = MergeMeta> {
36
+ /** Initial metadata before any sync or update. */
37
+ initialMeta: TMeta;
38
+ /** Produce meta when this peer sets state (e.g. timestamp, version). */
39
+ createMeta(): TMeta;
40
+ /**
41
+ * Decide how to merge incoming state with current.
42
+ * Return new { state, meta } to apply, or null to keep current.
43
+ */
44
+ merge(currentState: TState | null, currentMeta: TMeta, incomingState: TState | null, incomingMeta: TMeta, senderId: string): {
45
+ state: TState | null;
46
+ meta: TMeta;
47
+ } | null;
48
+ }
49
+
50
+ /**
51
+ * Metadata carried by the Lamport clock strategy.
52
+ * `clock` is a monotonically increasing logical counter; `tiebreaker` is the
53
+ * writing peer's ID, used to deterministically resolve equal-clock conflicts.
54
+ */
55
+ type LamportMeta = {
56
+ clock: number;
57
+ tiebreaker: string;
58
+ };
59
+ /**
60
+ * Creates a Lamport logical-clock merge strategy.
61
+ *
62
+ * Unlike wall-clock timestamps, Lamport clocks do not rely on peers having
63
+ * synchronised system clocks. The clock advances monotonically: it increments
64
+ * on every local write and is fast-forwarded to `max(local, incoming)` on
65
+ * every receive, so causal ordering is preserved across all peers.
66
+ *
67
+ * Concurrent writes (same clock value) are broken deterministically by
68
+ * comparing the writing peer's ID as a string, so every peer reaches the same
69
+ * result independently.
70
+ *
71
+ * @param getPeerId - A function that returns the local peer's ID at call time.
72
+ * Pass a ref-backed getter so the strategy stays stable even before the
73
+ * signalling handshake completes.
74
+ */
75
+ declare function createLamportStrategy(getPeerId: () => string): MergeStrategy<JSONSerializable, LamportMeta>;
76
+
77
+ /**
78
+ * Metadata carried by the Last Write Wins strategy.
79
+ * `timestamp` is a wall-clock time in milliseconds; `tiebreaker` is the
80
+ * writing peer's ID, used to deterministically resolve same-millisecond conflicts.
81
+ */
82
+ type LastWriteWinsMeta = {
83
+ timestamp: number;
84
+ tiebreaker: string;
85
+ };
86
+ /**
87
+ * Creates a Last Write Wins merge strategy.
88
+ *
89
+ * Accepts the incoming state whenever its wall-clock timestamp is strictly
90
+ * greater than the current one. Ties (same millisecond) are broken
91
+ * deterministically by comparing peer ID strings, so every peer converges to
92
+ * the same result independently without coordination.
93
+ *
94
+ * Note: this strategy relies on peers having reasonably synchronised system
95
+ * clocks. It is appropriate for use cases where approximate recency is
96
+ * sufficient (e.g. presence indicators, cursor positions, UI state), but
97
+ * should not be used where causal ordering must be guaranteed — prefer
98
+ * `createLamportStrategy` in that case.
99
+ *
100
+ * @param getPeerId - A function that returns the local peer's ID at call time.
101
+ * Pass a ref-backed getter so the strategy stays stable even before the
102
+ * signalling handshake completes.
103
+ */
104
+ declare function createLastWriteWinsStrategy(getPeerId: () => string): MergeStrategy<JSONSerializable, LastWriteWinsMeta>;
105
+
106
+ declare function useRoom(): RoomContextValue;
107
+
108
+ /**
109
+ * A hook that allows you to share state between multiple peers.
110
+ *
111
+ * Works hostlessly by:
112
+ *
113
+ * - Broadcasting updates to all peers under the given key; everyone keeps the
114
+ * causally latest state according to a Lamport logical clock.
115
+ * - Late joiners: when a data channel opens to a new peer, both sides push
116
+ * their current state to each other; the merge strategy keeps the winner.
117
+ *
118
+ * @param key - A string key that namespaces this shared state slice. Multiple calls with the same key share state; different keys are independent.
119
+ * @param initialState - The initial state of the shared state (used only before any sync or update).
120
+ * @returns A tuple containing the current state and a function to update the state.
121
+ */
122
+ declare function useSharedState<TState extends JSONSerializable>(key: string, initialState: TState | null): [state: TState | null, setState: (next: TState | null) => void];
123
+ /**
124
+ * A hook that allows you to share state between multiple peers with a custom merge strategy.
125
+ *
126
+ * @param key - A string key that namespaces this shared state slice.
127
+ * @param initialState - The initial state (used only before any sync or update).
128
+ * @param strategy - A merge strategy controlling how incoming state is reconciled.
129
+ * @returns A tuple containing the current state and a function to update the state.
130
+ */
131
+ declare function useSharedState<TState extends JSONSerializable, TMeta extends MergeMeta>(key: string, initialState: TState | null, strategy: MergeStrategy<TState, TMeta>): [state: TState | null, setState: (next: TState | null) => void];
132
+
133
+ export { type JSONSerializable, type LamportMeta, type LastWriteWinsMeta, type MergeMeta, type MergeStrategy, type Message, type MessageHandler, Room, RoomContext, type RoomContextValue, createLamportStrategy, createLastWriteWinsStrategy, useRoom, useSharedState };