use-everywhere 0.2.0 → 0.3.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.
@@ -0,0 +1,90 @@
1
+ // src/registry.ts
2
+ import {
3
+ createChannel,
4
+ createLeader,
5
+ createPresence,
6
+ createSharedStore,
7
+ DEFAULT_NAME,
8
+ NoopTransport
9
+ } from "@use-everywhere/core";
10
+ var stores = /* @__PURE__ */ new Map();
11
+ var presences = /* @__PURE__ */ new Map();
12
+ var channels = /* @__PURE__ */ new Map();
13
+ var leaders = /* @__PURE__ */ new Map();
14
+ var storeConfig = /* @__PURE__ */ new Map();
15
+ var scopeOptions = {
16
+ everywhere: {},
17
+ tabs: { accept: (meta) => meta.kind !== "worker" },
18
+ tab: { transport: () => new NoopTransport() }
19
+ };
20
+ function getSharedStore(name = DEFAULT_NAME, scope = "everywhere") {
21
+ return getStore(name, scope);
22
+ }
23
+ function configureStore(name, scope, options) {
24
+ const key = `${scope} ${name}`;
25
+ if (stores.has(key)) {
26
+ throw new Error(
27
+ `defineStore('${name}') ran after that store was already created. Move it to module scope \u2014 configuring a live store would silently hand you one without persistence.`
28
+ );
29
+ }
30
+ storeConfig.set(key, options);
31
+ }
32
+ function getStore(name, scope = "everywhere") {
33
+ const key = `${scope} ${name}`;
34
+ let store = stores.get(key);
35
+ if (!store) {
36
+ store = createSharedStore(name, {}, { ...scopeOptions[scope], ...storeConfig.get(key) });
37
+ stores.set(key, store);
38
+ }
39
+ return store;
40
+ }
41
+ function getPresence(name) {
42
+ let presence = presences.get(name);
43
+ if (!presence) {
44
+ presence = createPresence(name);
45
+ presences.set(name, presence);
46
+ }
47
+ return presence;
48
+ }
49
+ function getLeader(name, options) {
50
+ let leader = leaders.get(name);
51
+ if (!leader) {
52
+ leader = createLeader(name, options);
53
+ leaders.set(name, leader);
54
+ }
55
+ return leader;
56
+ }
57
+ function getChannel(name) {
58
+ let channel = channels.get(name);
59
+ if (!channel) {
60
+ channel = createChannel(name);
61
+ channels.set(name, channel);
62
+ }
63
+ return channel;
64
+ }
65
+
66
+ // src/use-peers.ts
67
+ import { useCallback, useSyncExternalStore } from "react";
68
+ var NO_PEERS = Object.freeze([]);
69
+ function usePeers(options) {
70
+ const presence = getPresence(options?.name ?? DEFAULT_NAME);
71
+ return useSyncExternalStore(
72
+ useCallback((onChange) => presence.subscribe(onChange), [presence]),
73
+ () => presence.getPeers(),
74
+ () => NO_PEERS
75
+ );
76
+ }
77
+ function useClientId(options) {
78
+ return getPresence(options?.name ?? DEFAULT_NAME).clientId;
79
+ }
80
+
81
+ export {
82
+ DEFAULT_NAME,
83
+ getSharedStore,
84
+ configureStore,
85
+ getStore,
86
+ getLeader,
87
+ getChannel,
88
+ usePeers,
89
+ useClientId
90
+ };
@@ -0,0 +1,35 @@
1
+ import * as react from 'react';
2
+
3
+ interface InspectorProps {
4
+ /** Which bus to watch. Defaults to the shared default name. */
5
+ name?: string;
6
+ /** Corner to dock in. Default 'bottom-right'. */
7
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
8
+ /** How many wires to keep in the log. Default 50. */
9
+ limit?: number;
10
+ /** Start expanded. Default false. */
11
+ defaultOpen?: boolean;
12
+ /**
13
+ * How long a leader wire keeps the crown before it is treated as stale.
14
+ * Should match the Leader's leaseMs. Default 3000.
15
+ */
16
+ leaseMs?: number;
17
+ }
18
+
19
+ /**
20
+ * A floating panel showing what this tab is saying and hearing on the bus:
21
+ * peers, the leader, store keys with their version clocks, and a live wire log
22
+ * in both directions.
23
+ *
24
+ * It deliberately does **not** create a Leader. Under dynamic eligibility,
25
+ * mounting one with `eligible: false` would disable candidacy for the whole
26
+ * tab, and mounting a plain one would enrol a tab that never asked to be a
27
+ * candidate — a devtool must not change what it measures. Instead it reads the
28
+ * crown out of the wire log, which it already sees in both directions.
29
+ *
30
+ * Presence is fine to use: the bus heartbeats regardless of whether anything
31
+ * created a Presence, so usePeers observes rather than perturbs.
32
+ */
33
+ declare function Inspector({ name, position, limit, defaultOpen, leaseMs, }?: InspectorProps): react.JSX.Element;
34
+
35
+ export { Inspector, type InspectorProps };
@@ -0,0 +1,216 @@
1
+ import {
2
+ getSharedStore,
3
+ usePeers
4
+ } from "../chunk-Y67BCTU6.js";
5
+
6
+ // src/devtools/inspector.tsx
7
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
8
+ import { DEFAULT_NAME, observeBus } from "@use-everywhere/core";
9
+
10
+ // src/devtools/styles.ts
11
+ var STYLES = `
12
+ .ue-ins {
13
+ position: fixed;
14
+ z-index: 2147483000;
15
+ font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
16
+ color: #e6edf3;
17
+ background: #0d1117;
18
+ border: 1px solid #30363d;
19
+ border-radius: 8px;
20
+ box-shadow: 0 8px 32px rgb(0 0 0 / 0.4);
21
+ max-width: min(420px, calc(100vw - 24px));
22
+ overflow: hidden;
23
+ }
24
+ .ue-ins--bottom-right { bottom: 12px; right: 12px; }
25
+ .ue-ins--bottom-left { bottom: 12px; left: 12px; }
26
+ .ue-ins--top-right { top: 12px; right: 12px; }
27
+ .ue-ins--top-left { top: 12px; left: 12px; }
28
+
29
+ .ue-ins__bar {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 8px;
33
+ width: 100%;
34
+ padding: 7px 10px;
35
+ background: #161b22;
36
+ border: 0;
37
+ color: inherit;
38
+ font: inherit;
39
+ cursor: pointer;
40
+ text-align: left;
41
+ }
42
+ .ue-ins__dot { width: 7px; height: 7px; border-radius: 50%; background: #3fb950; flex: none; }
43
+ .ue-ins__title { font-weight: 600; }
44
+ .ue-ins__muted { color: #8b949e; }
45
+ .ue-ins__crown { margin-left: auto; color: #d29922; }
46
+
47
+ .ue-ins__body { max-height: 60vh; overflow-y: auto; }
48
+ .ue-ins__section { border-top: 1px solid #21262d; padding: 8px 10px; }
49
+ .ue-ins__h {
50
+ color: #8b949e;
51
+ text-transform: uppercase;
52
+ letter-spacing: 0.06em;
53
+ font-size: 10px;
54
+ margin-bottom: 5px;
55
+ }
56
+ .ue-ins__row { display: flex; gap: 8px; padding: 1px 0; }
57
+ .ue-ins__k { color: #79c0ff; flex: none; }
58
+ .ue-ins__v { color: #e6edf3; overflow-wrap: anywhere; }
59
+ .ue-ins__ver { color: #6e7681; margin-left: auto; flex: none; }
60
+ .ue-ins__empty { color: #6e7681; }
61
+
62
+ .ue-ins__log { display: flex; flex-direction: column-reverse; max-height: 190px; overflow-y: auto; }
63
+ .ue-ins__wire { display: flex; gap: 7px; padding: 1px 0; white-space: nowrap; }
64
+ .ue-ins__dir { flex: none; width: 9px; }
65
+ .ue-ins__dir--out { color: #d29922; }
66
+ .ue-ins__dir--in { color: #3fb950; }
67
+ .ue-ins__scope { color: #e6edf3; }
68
+ .ue-ins__from { color: #6e7681; margin-left: auto; }
69
+ `;
70
+
71
+ // src/devtools/inspector.tsx
72
+ import { jsx, jsxs } from "react/jsx-runtime";
73
+ var short = (id) => id.slice(0, 6);
74
+ function wireLabel(wire) {
75
+ return `${wire.scope}/${wire.type}`;
76
+ }
77
+ function Inspector({
78
+ name = DEFAULT_NAME,
79
+ position = "bottom-right",
80
+ limit = 50,
81
+ defaultOpen = false,
82
+ leaseMs = 3e3
83
+ } = {}) {
84
+ const [open, setOpen] = useState(defaultOpen);
85
+ const [wires, setWires] = useState([]);
86
+ const [crown, setCrown] = useState(null);
87
+ const nextId = useRef(0);
88
+ const crownAt = useRef(0);
89
+ const peers = usePeers({ name });
90
+ const store = getSharedStore(name);
91
+ const subscribe = useCallback((onChange) => store.subscribe(onChange), [store]);
92
+ const snapshot = useSyncExternalStore(
93
+ subscribe,
94
+ () => store.getSnapshot(),
95
+ () => store.getSnapshot()
96
+ );
97
+ const versions = useSyncExternalStore(
98
+ subscribe,
99
+ () => store.getVersions(),
100
+ () => store.getVersions()
101
+ );
102
+ useEffect(() => {
103
+ return observeBus(name, (event) => {
104
+ const { wire, direction } = event;
105
+ if (wire.scope === "leader") {
106
+ if (wire.type === "resign") {
107
+ setCrown(null);
108
+ crownAt.current = 0;
109
+ } else if (wire.type === "claim" || wire.type === "heartbeat") {
110
+ setCrown(wire.clientId);
111
+ crownAt.current = Date.now();
112
+ }
113
+ }
114
+ setWires(
115
+ (prev) => [
116
+ ...prev.slice(-(limit - 1)),
117
+ {
118
+ id: nextId.current++,
119
+ direction,
120
+ label: wireLabel(wire),
121
+ from: short(wire.clientId)
122
+ }
123
+ ].slice(-limit)
124
+ );
125
+ });
126
+ }, [name, limit]);
127
+ useEffect(() => {
128
+ const timer = setInterval(
129
+ () => {
130
+ if (crownAt.current && Date.now() - crownAt.current > leaseMs) {
131
+ setCrown(null);
132
+ crownAt.current = 0;
133
+ }
134
+ },
135
+ // Quarter of the lease, so a vacated crown clears well within it. The
136
+ // floor is low enough that a short lease still works rather than being
137
+ // silently rounded up to something coarser than the lease itself.
138
+ Math.max(50, Math.floor(leaseMs / 4))
139
+ );
140
+ return () => clearInterval(timer);
141
+ }, [leaseMs]);
142
+ const selfId = store.clientId;
143
+ const entries = Object.entries(versions);
144
+ return /* @__PURE__ */ jsxs("div", { className: `ue-ins ue-ins--${position}`, "data-testid": "ue-inspector", children: [
145
+ /* @__PURE__ */ jsx("style", { children: STYLES }),
146
+ /* @__PURE__ */ jsxs(
147
+ "button",
148
+ {
149
+ type: "button",
150
+ className: "ue-ins__bar",
151
+ onClick: () => setOpen((v) => !v),
152
+ "aria-expanded": open,
153
+ children: [
154
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__dot" }),
155
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__title", children: "use-everywhere" }),
156
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__muted", children: name }),
157
+ crown ? /* @__PURE__ */ jsxs("span", { className: "ue-ins__crown", "data-testid": "ue-crown", children: [
158
+ "\u2654 ",
159
+ crown === selfId ? "this tab" : short(crown)
160
+ ] }) : null
161
+ ]
162
+ }
163
+ ),
164
+ open ? /* @__PURE__ */ jsxs("div", { className: "ue-ins__body", children: [
165
+ /* @__PURE__ */ jsxs("div", { className: "ue-ins__section", children: [
166
+ /* @__PURE__ */ jsx("div", { className: "ue-ins__h", children: "This tab" }),
167
+ /* @__PURE__ */ jsxs("div", { className: "ue-ins__row", children: [
168
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__k", children: short(selfId) }),
169
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__v", children: crown === selfId ? "leader" : crown ? "follower" : "no leader" })
170
+ ] })
171
+ ] }),
172
+ /* @__PURE__ */ jsxs("div", { className: "ue-ins__section", children: [
173
+ /* @__PURE__ */ jsxs("div", { className: "ue-ins__h", children: [
174
+ "Peers (",
175
+ peers.length,
176
+ ")"
177
+ ] }),
178
+ peers.length === 0 ? /* @__PURE__ */ jsx("div", { className: "ue-ins__empty", children: "nobody else here" }) : peers.map((peer) => /* @__PURE__ */ jsxs("div", { className: "ue-ins__row", children: [
179
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__k", children: short(peer.id) }),
180
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__v", children: peer.kind })
181
+ ] }, peer.id))
182
+ ] }),
183
+ /* @__PURE__ */ jsxs("div", { className: "ue-ins__section", children: [
184
+ /* @__PURE__ */ jsxs("div", { className: "ue-ins__h", children: [
185
+ "State (",
186
+ entries.length,
187
+ ")"
188
+ ] }),
189
+ entries.length === 0 ? /* @__PURE__ */ jsx("div", { className: "ue-ins__empty", children: "no keys yet" }) : entries.map(([key, version]) => /* @__PURE__ */ jsxs("div", { className: "ue-ins__row", children: [
190
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__k", children: key }),
191
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__v", children: JSON.stringify(snapshot[key]) }),
192
+ /* @__PURE__ */ jsxs("span", { className: "ue-ins__ver", children: [
193
+ version[0],
194
+ "\xB7",
195
+ short(version[1])
196
+ ] })
197
+ ] }, key))
198
+ ] }),
199
+ /* @__PURE__ */ jsxs("div", { className: "ue-ins__section", children: [
200
+ /* @__PURE__ */ jsxs("div", { className: "ue-ins__h", children: [
201
+ "Wires (",
202
+ wires.length,
203
+ ")"
204
+ ] }),
205
+ wires.length === 0 ? /* @__PURE__ */ jsx("div", { className: "ue-ins__empty", children: "nothing yet" }) : /* @__PURE__ */ jsx("div", { className: "ue-ins__log", children: wires.map((wire) => /* @__PURE__ */ jsxs("div", { className: "ue-ins__wire", children: [
206
+ /* @__PURE__ */ jsx("span", { className: `ue-ins__dir ue-ins__dir--${wire.direction}`, children: wire.direction === "out" ? "\u2192" : "\u2190" }),
207
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__scope", children: wire.label }),
208
+ /* @__PURE__ */ jsx("span", { className: "ue-ins__from", children: wire.from })
209
+ ] }, wire.id)) })
210
+ ] })
211
+ ] }) : null
212
+ ] });
213
+ }
214
+ export {
215
+ Inspector
216
+ };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { SharedStore, MessageMap, Channel, MessageMeta, Peer, OpenedWindow } from '@use-everywhere/core';
1
+ import { SharedStore, LeaderOptions, Leader, MessageMap, Channel, MessageMeta, PersistAdapter, Peer, LeaderSnapshot, OpenedWindow } from '@use-everywhere/core';
2
2
  export * from '@use-everywhere/core';
3
+ export { DEFAULT_NAME } from '@use-everywhere/core';
3
4
 
4
5
  /**
5
6
  * How far a shared value travels:
@@ -26,14 +27,15 @@ declare function useSharedState<T>(key: string, initial: T, options?: UseSharedS
26
27
  /** Registry stores hold arbitrary keys — shape is decided by the hooks using them. */
27
28
  type AnyStore = SharedStore<Record<string, unknown>>;
28
29
 
29
- /**
30
- * A BroadcastChannel is already global to the origin — identity is the name
31
- * string, not the React tree — so hooks share module-level singletons per
32
- * name instead of requiring a Provider. Instances live for the page lifetime.
33
- */
34
- declare const DEFAULT_NAME = "use-everywhere";
35
30
  /** Imperative access to the store behind useSharedState (patch logs, non-React code). */
36
31
  declare function getSharedStore(name?: string, scope?: ShareScope): AnyStore;
32
+ /**
33
+ * One Leader per name per tab. Eligibility is deliberately *not* part of the
34
+ * key: two Leaders on one name would share a bus and a clientId, and since a
35
+ * post never loops back locally, neither would ever see the other's claims.
36
+ * Timing options are first-wins, like every other engine here.
37
+ */
38
+ declare function getLeader(name: string, options?: LeaderOptions): Leader;
37
39
 
38
40
  /** Get the page-wide typed channel for `name` (one instance per name). */
39
41
  declare function useChannel<M extends MessageMap>(name: string): Channel<M>;
@@ -66,6 +68,39 @@ interface ChannelHooks<M extends MessageMap> {
66
68
  */
67
69
  declare function defineChannel<M extends MessageMap>(name: string): ChannelHooks<M>;
68
70
 
71
+ interface DefineStoreOptions {
72
+ /** Restore this store from disk on first use, and write it back as it changes. */
73
+ persist?: PersistAdapter;
74
+ /** Persist only these keys. Default: every key that has been written. */
75
+ persistKeys?: string[];
76
+ /** Coalesce disk writes for this long. Default 100. */
77
+ persistDebounceMs?: number;
78
+ /** How far this store is shared. Default 'everywhere'. */
79
+ scope?: ShareScope;
80
+ }
81
+ /** A store bound to a name and a shape: typed hooks with no per-call generics. */
82
+ interface StoreHooks<S extends Record<string, unknown>> {
83
+ /** The underlying store instance (the same one the hooks use) — for non-React code. */
84
+ get: () => AnyStore;
85
+ useSharedState: <K extends keyof S & string>(key: K, initial: S[K]) => [S[K], (next: S[K] | ((prev: S[K]) => S[K])) => void];
86
+ }
87
+
88
+ /**
89
+ * Bind a store name — and optionally persistence — once, at module level.
90
+ *
91
+ * Like defineChannel, this does not construct anything: it registers the
92
+ * options the registry will use when the store is first needed, so importing
93
+ * the module has no side effect. The store stays a singleton per name, so
94
+ * `defineStore('settings', { persist })` and a bare
95
+ * `useSharedState('theme', 'dark', { store: 'settings' })` elsewhere resolve to
96
+ * the same store, and both get persistence.
97
+ *
98
+ * Must run before that store exists. Module evaluation always precedes render,
99
+ * so intended usage is automatic; if it does run late it throws rather than
100
+ * quietly handing back a store with no persistence.
101
+ */
102
+ declare function defineStore<S extends Record<string, unknown> = Record<string, unknown>>(name: string, options?: DefineStoreOptions): StoreHooks<S>;
103
+
69
104
  /** The other tabs/windows/workers currently alive on this origin. */
70
105
  declare function usePeers(options?: {
71
106
  name?: string;
@@ -75,6 +110,25 @@ declare function useClientId(options?: {
75
110
  name?: string;
76
111
  }): string;
77
112
 
113
+ interface UseLeaderOptions extends LeaderOptions {
114
+ /** Which bus to elect on. Defaults to the shared default name. */
115
+ name?: string;
116
+ }
117
+
118
+ /**
119
+ * Who currently holds the seat on this bus, and whether it is us. Exactly one
120
+ * tab leads; opening another does not steal it, and closing the leader hands it
121
+ * over at once.
122
+ */
123
+ declare function useLeader(options?: UseLeaderOptions): LeaderSnapshot;
124
+ /** Is this tab the leader? */
125
+ declare function useIsLeader(options?: UseLeaderOptions): boolean;
126
+ /**
127
+ * Run an effect only in the tab that holds the seat, and tear it down when the
128
+ * seat moves. The one place to put "exactly one tab owns the socket".
129
+ */
130
+ declare function useLeaderEffect(effect: () => void | (() => void), options?: UseLeaderOptions): void;
131
+
78
132
  type OpenedWindowStatus = 'idle' | 'opening' | 'connected' | 'done' | 'closed-early' | 'error';
79
133
  interface UseOpenedWindow<Out extends MessageMap, In extends MessageMap, R> {
80
134
  /** Call from a click handler (popup blockers require a user gesture). */
@@ -95,4 +149,4 @@ interface UseOpenedWindow<Out extends MessageMap, In extends MessageMap, R> {
95
149
  */
96
150
  declare function useOpenedWindow<Out extends MessageMap, In extends MessageMap, R = unknown>(factory: () => OpenedWindow<Out, In, R>): UseOpenedWindow<Out, In, R>;
97
151
 
98
- export { type AnyStore, type ChannelHooks, DEFAULT_NAME, type OpenedWindowStatus, type ShareScope, type UseOpenedWindow, type UseSharedStateOptions, defineChannel, getSharedStore, useChannel, useClientId, useMessage, useOpenedWindow, usePeers, useSend, useSharedState };
152
+ export { type AnyStore, type ChannelHooks, type DefineStoreOptions, type OpenedWindowStatus, type ShareScope, type StoreHooks, type UseLeaderOptions, type UseOpenedWindow, type UseSharedStateOptions, defineChannel, defineStore, getLeader, getSharedStore, useChannel, useClientId, useIsLeader, useLeader, useLeaderEffect, useMessage, useOpenedWindow, usePeers, useSend, useSharedState };
package/dist/index.js CHANGED
@@ -1,52 +1,16 @@
1
- // src/use-shared-state.ts
2
- import { useCallback, useSyncExternalStore } from "react";
3
-
4
- // src/registry.ts
5
1
  import {
6
- createChannel,
7
- createPresence,
8
- createSharedStore,
9
- NoopTransport
10
- } from "@use-everywhere/core";
11
- var DEFAULT_NAME = "use-everywhere";
12
- var stores = /* @__PURE__ */ new Map();
13
- var presences = /* @__PURE__ */ new Map();
14
- var channels = /* @__PURE__ */ new Map();
15
- var scopeOptions = {
16
- everywhere: {},
17
- tabs: { accept: (meta) => meta.kind !== "worker" },
18
- tab: { transport: () => new NoopTransport() }
19
- };
20
- function getSharedStore(name = DEFAULT_NAME, scope = "everywhere") {
21
- return getStore(name, scope);
22
- }
23
- function getStore(name, scope = "everywhere") {
24
- const key = `${scope}\0${name}`;
25
- let store = stores.get(key);
26
- if (!store) {
27
- store = createSharedStore(name, {}, scopeOptions[scope]);
28
- stores.set(key, store);
29
- }
30
- return store;
31
- }
32
- function getPresence(name) {
33
- let presence = presences.get(name);
34
- if (!presence) {
35
- presence = createPresence(name);
36
- presences.set(name, presence);
37
- }
38
- return presence;
39
- }
40
- function getChannel(name) {
41
- let channel = channels.get(name);
42
- if (!channel) {
43
- channel = createChannel(name);
44
- channels.set(name, channel);
45
- }
46
- return channel;
47
- }
2
+ DEFAULT_NAME,
3
+ configureStore,
4
+ getChannel,
5
+ getLeader,
6
+ getSharedStore,
7
+ getStore,
8
+ useClientId,
9
+ usePeers
10
+ } from "./chunk-Y67BCTU6.js";
48
11
 
49
12
  // src/use-shared-state.ts
13
+ import { useCallback, useSyncExternalStore } from "react";
50
14
  function useSharedState(key, initial, options) {
51
15
  const store = getStore(options?.store ?? DEFAULT_NAME, options?.scope ?? "everywhere");
52
16
  store.registerKey(key, initial);
@@ -92,30 +56,64 @@ function defineChannel(name) {
92
56
  };
93
57
  }
94
58
 
95
- // src/use-peers.ts
96
- import { useCallback as useCallback2, useSyncExternalStore as useSyncExternalStore2 } from "react";
97
- var NO_PEERS = Object.freeze([]);
98
- function usePeers(options) {
99
- const presence = getPresence(options?.name ?? DEFAULT_NAME);
59
+ // src/define-store.ts
60
+ function defineStore(name, options = {}) {
61
+ const scope = options.scope ?? "everywhere";
62
+ if (options.persist) {
63
+ configureStore(name, scope, {
64
+ persist: {
65
+ adapter: options.persist,
66
+ ...options.persistKeys ? { keys: options.persistKeys } : {},
67
+ ...options.persistDebounceMs === void 0 ? {} : { debounceMs: options.persistDebounceMs }
68
+ }
69
+ });
70
+ }
71
+ return {
72
+ get: () => getStore(name, scope),
73
+ useSharedState: (key, initial) => useSharedState(key, initial, { store: name, scope })
74
+ };
75
+ }
76
+
77
+ // src/use-leader.ts
78
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useSyncExternalStore as useSyncExternalStore2 } from "react";
79
+ var NO_LEADER = Object.freeze({ leaderId: null, isLeader: false });
80
+ function useLeader(options) {
81
+ const leader = getLeader(options?.name ?? DEFAULT_NAME, options);
82
+ const eligible = options?.eligible;
83
+ useEffect2(() => {
84
+ if (eligible === void 0) return;
85
+ leader.setEligible(eligible);
86
+ }, [leader, eligible]);
100
87
  return useSyncExternalStore2(
101
- useCallback2((onChange) => presence.subscribe(onChange), [presence]),
102
- () => presence.getPeers(),
103
- () => NO_PEERS
88
+ useCallback2((onChange) => leader.subscribe(onChange), [leader]),
89
+ () => leader.getSnapshot(),
90
+ () => NO_LEADER
104
91
  );
105
92
  }
106
- function useClientId(options) {
107
- return getPresence(options?.name ?? DEFAULT_NAME).clientId;
93
+ function useIsLeader(options) {
94
+ return useLeader(options).isLeader;
95
+ }
96
+ function useLeaderEffect(effect, options) {
97
+ const { isLeader } = useLeader(options);
98
+ const effectRef = useRef2(effect);
99
+ useEffect2(() => {
100
+ effectRef.current = effect;
101
+ });
102
+ useEffect2(() => {
103
+ if (!isLeader) return;
104
+ return effectRef.current();
105
+ }, [isLeader]);
108
106
  }
109
107
 
110
108
  // src/use-opened-window.ts
111
- import { useCallback as useCallback3, useRef as useRef2, useState } from "react";
109
+ import { useCallback as useCallback3, useRef as useRef3, useState } from "react";
112
110
  import { WindowClosedError } from "@use-everywhere/core";
113
111
  function useOpenedWindow(factory) {
114
112
  const [status, setStatus] = useState("idle");
115
113
  const [result, setResult] = useState(void 0);
116
114
  const [error, setError] = useState(void 0);
117
- const current = useRef2(null);
118
- const factoryRef = useRef2(factory);
115
+ const current = useRef3(null);
116
+ const factoryRef = useRef3(factory);
119
117
  factoryRef.current = factory;
120
118
  const open = useCallback3(() => {
121
119
  current.current?.close();
@@ -165,9 +163,14 @@ export * from "@use-everywhere/core";
165
163
  export {
166
164
  DEFAULT_NAME,
167
165
  defineChannel,
166
+ defineStore,
167
+ getLeader,
168
168
  getSharedStore,
169
169
  useChannel,
170
170
  useClientId,
171
+ useIsLeader,
172
+ useLeader,
173
+ useLeaderEffect,
171
174
  useMessage,
172
175
  useOpenedWindow,
173
176
  usePeers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "use-everywhere",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "React hooks for state and messages shared across tabs, windows, and workers",
5
5
  "license": "MIT",
6
6
  "author": "Jonatan Kruszewski <jonakrusze@gmail.com>",
@@ -32,6 +32,10 @@
32
32
  ".": {
33
33
  "types": "./dist/index.d.ts",
34
34
  "import": "./dist/index.js"
35
+ },
36
+ "./devtools": {
37
+ "types": "./dist/devtools/index.d.ts",
38
+ "import": "./dist/devtools/index.js"
35
39
  }
36
40
  },
37
41
  "files": [
@@ -42,13 +46,13 @@
42
46
  "name": "everything (import *)",
43
47
  "path": "dist/index.js",
44
48
  "import": "*",
45
- "limit": "4.2 kB"
49
+ "limit": "5.4 kB"
46
50
  },
47
51
  {
48
52
  "name": "useSharedState",
49
53
  "path": "dist/index.js",
50
54
  "import": "{ useSharedState }",
51
- "limit": "1.5 kB"
55
+ "limit": "1.8 kB"
52
56
  },
53
57
  {
54
58
  "name": "useChannel + useMessage + useSend",
@@ -67,10 +71,28 @@
67
71
  "path": "dist/index.js",
68
72
  "import": "{ useOpenedWindow, openWindow }",
69
73
  "limit": "1.4 kB"
74
+ },
75
+ {
76
+ "name": "useLeader + useLeaderEffect",
77
+ "path": "dist/index.js",
78
+ "import": "{ useLeader, useLeaderEffect }",
79
+ "limit": "1.8 kB"
80
+ },
81
+ {
82
+ "name": "defineStore (persisted)",
83
+ "path": "dist/index.js",
84
+ "import": "{ defineStore, localStorageAdapter }",
85
+ "limit": "2.3 kB"
86
+ },
87
+ {
88
+ "name": "Inspector (devtools subpath)",
89
+ "path": "dist/devtools/index.js",
90
+ "import": "{ Inspector }",
91
+ "limit": "5 kB"
70
92
  }
71
93
  ],
72
94
  "dependencies": {
73
- "@use-everywhere/core": "0.2.0"
95
+ "@use-everywhere/core": "0.3.0"
74
96
  },
75
97
  "peerDependencies": {
76
98
  "react": ">=18"
@@ -89,6 +111,13 @@
89
111
  "typescript": "^5.8.3",
90
112
  "vitest": "^4.1.10"
91
113
  },
114
+ "typesVersions": {
115
+ "*": {
116
+ "devtools": [
117
+ "./dist/devtools/index.d.ts"
118
+ ]
119
+ }
120
+ },
92
121
  "scripts": {
93
122
  "build": "tsup",
94
123
  "test": "vitest run --coverage",