@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 +84 -0
- package/dist/index.d.mts +133 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.js +506 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +474 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +52 -0
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)
|
package/dist/index.d.mts
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|