use-everywhere 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # use-everywhere
2
2
 
3
- React hooks for state and messages that exist in every tab, window, and worker.
3
+ React hooks for state and messages that exist in every tab, window, and
4
+ worker — plus a secure channel to windows on other origins.
4
5
 
5
6
  ```bash
6
7
  npm i use-everywhere
@@ -8,72 +9,148 @@ npm i use-everywhere
8
9
 
9
10
  Two transports behind one library:
10
11
 
11
- - **BroadcastChannel** (same-origin): shared state with last-writer-wins version
12
- clocks and a late-joiner handshake, typed pub/sub events, and peer presence.
12
+ - **BroadcastChannel** (same-origin): shared state with last-writer-wins
13
+ version clocks and a late-joiner handshake, typed pub/sub events, and peer
14
+ presence.
13
15
  - **window.opener / postMessage** (cross-origin): a secure 1:1 channel to a
14
16
  window you opened — e.g. a payment page on another domain that must report
15
- back to the checkout that opened it. Every message is validated by origin,
16
- envelope brand, a per-connection nonce, and the source window.
17
+ back to the checkout that opened it.
17
18
 
18
- ## Usage
19
+ ## Shared state: `useSharedState`
20
+
21
+ `useState`, but the value exists in every tab on your origin. Late-joining
22
+ tabs hydrate to the current value; concurrent writes converge to one winner.
23
+
24
+ ```tsx
25
+ import { useSharedState } from 'use-everywhere';
26
+
27
+ function Counter() {
28
+ const [count, setCount] = useSharedState('count', 0);
29
+ return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
30
+ }
31
+ ```
32
+
33
+ The third argument delimits how far a value travels:
19
34
 
20
35
  ```tsx
21
- import {
22
- useSharedState,
23
- useChannel,
24
- useMessage,
25
- usePeers,
26
- useOpenedWindow,
27
- openWindow,
28
- } from 'use-everywhere';
29
-
30
- // useState, but the value exists in every tab/window/worker on this origin.
31
- const [count, setCount] = useSharedState('count', 0);
32
-
33
- // Typed fire-and-forget events between tabs.
34
- const channel = useChannel<{ 'cart-updated': { items: number } }>('shop');
35
- useMessage(channel, 'cart-updated', ({ items }) => refresh(items));
36
- channel.post('cart-updated', { items: 3 });
37
-
38
- // Who else is here?
39
- const peers = usePeers();
40
-
41
- // Open a window on ANOTHER origin and await its result.
42
- const pay = useOpenedWindow(() =>
43
- openWindow<ToPayment, FromPayment, Receipt>('https://pay.example.com/checkout', {
44
- peerOrigin: 'https://pay.example.com',
45
- }),
46
- );
47
- // pay.open() from a click handler; pay.status: idle → opening → connected → done
48
- // pay.result is the child's finish() value; closing early yields 'closed-early'.
36
+ useSharedState('draft', '', { scope: 'everywhere' }); // tabs + windows + workers (default)
37
+ useSharedState('draft', '', { scope: 'tabs' }); // ignore writes from workers
38
+ useSharedState('draft', '', { scope: 'tab' }); // this tab only
49
39
  ```
50
40
 
51
- On the opened (child) page:
41
+ ## Events: `defineChannel`
42
+
43
+ Typed fire-and-forget messages for things that _happen_ (state is for things
44
+ that _are_). Not echoed to the sender; no history for late joiners. Bind the
45
+ channel's name and message map once at module level; every component gets
46
+ fully typed hooks with nothing to repeat:
47
+
48
+ ```tsx
49
+ import { defineChannel } from 'use-everywhere';
50
+ import { useState } from 'react';
51
+
52
+ type ShopEvents = { 'cart-updated': { items: number } };
53
+ const shop = defineChannel<ShopEvents>('shop');
54
+
55
+ function CartBadge() {
56
+ const [items, setItems] = useState(0);
57
+ const send = shop.useSend();
58
+
59
+ // Fires when any OTHER tab posts 'cart-updated'.
60
+ shop.useMessage('cart-updated', (payload) => setItems(payload.items));
61
+
62
+ const addToCart = () => {
63
+ setItems(items + 1); // this tab
64
+ send('cart-updated', { items: items + 1 }); // every other tab
65
+ };
66
+
67
+ return <button onClick={addToCart}>Cart ({items})</button>;
68
+ }
69
+ ```
52
70
 
53
- ```ts
71
+ The standalone hooks — `useChannel(name)`, `useMessage(channel, type,
72
+ handler)`, `useSend(channel)` — are the same machinery without the
73
+ module-level binding, for one-off use.
74
+
75
+ ## Presence: `usePeers`
76
+
77
+ ```tsx
78
+ import { usePeers } from 'use-everywhere';
79
+
80
+ function DuplicateTabWarning() {
81
+ const peers = usePeers();
82
+ if (peers.length === 0) return null;
83
+ return <p>⚠ This page is open in {peers.length} other tab(s).</p>;
84
+ }
85
+ ```
86
+
87
+ ## Cross-origin windows: `useOpenedWindow`
88
+
89
+ Open a window on another domain, exchange typed messages, and await its
90
+ result — the whole lifecycle folded into render state.
91
+
92
+ ```tsx
93
+ import { openWindow, useOpenedWindow } from 'use-everywhere';
94
+
95
+ type ToPayment = { order: { orderId: string; amount: string } };
96
+ type FromPayment = { progress: { step: string } };
97
+ type Receipt = { receiptId: string; last4: string };
98
+
99
+ function PayButton() {
100
+ const pay = useOpenedWindow<ToPayment, FromPayment, Receipt>(() =>
101
+ openWindow('https://pay.example.com/checkout', {
102
+ peerOrigin: 'https://pay.example.com', // required — '*' throws
103
+ }),
104
+ );
105
+
106
+ if (pay.status === 'done') return <p>Paid — receipt {pay.result!.receiptId}</p>;
107
+ if (pay.status === 'closed-early') return <p>Payment window was closed.</p>;
108
+
109
+ return (
110
+ <button onClick={pay.open} disabled={pay.status !== 'idle'}>
111
+ {pay.status === 'idle' ? 'Pay in secure window' : 'Waiting for payment…'}
112
+ </button>
113
+ );
114
+ }
115
+ ```
116
+
117
+ On the opened page (the other domain), use the core API:
118
+
119
+ ```tsx
54
120
  import { connectToOpener } from 'use-everywhere';
55
121
 
56
122
  const conn = connectToOpener<ToPayment, FromPayment, Receipt>({
57
123
  peerOrigin: 'https://shop.example.com',
58
124
  });
59
- conn.on('order', (order) => render(order));
60
- conn.finish({ receiptId: 'r-123', last4: '4242' }); // resolves the opener's result
125
+ conn.on('order', (order) => setOrder(order)); // e.g. a useState setter
126
+ conn.finish({ receiptId: 'r-123', last4: '4242' }); // resolves the opener's pay.result
61
127
  ```
62
128
 
129
+ Messages sent before the (possibly slow-loading) child connects are queued,
130
+ never dropped, and every received message is validated by origin, envelope,
131
+ per-connection nonce, and source window.
132
+
63
133
  ## Design notes
64
134
 
65
135
  - **Shared state never crosses origins.** Two origins are two trust domains;
66
136
  the cross-origin channel is explicit, per-message, and typed.
67
137
  - **No Provider.** A BroadcastChannel is already global to the origin —
68
138
  identity is the channel name, so hooks share module-level singletons.
69
- - Values must survive structured clone (no functions, DOM nodes, etc.).
139
+ Imperative access to the same stores: `getSharedStore(name)`.
140
+ - SSR-safe: hooks render initial values on the server via
141
+ `getServerSnapshot`; no `BroadcastChannel` needed there.
142
+ - Values must survive structured clone (no functions, DOM nodes); state lives
143
+ as long as at least one context holds it — nothing is persisted.
144
+ - Testing is first-class: inject a `MemoryHub` transport to simulate many tabs
145
+ in one test. See the [testing guide](https://rxova.github.io/use-everywhere/guides/testing).
70
146
 
71
147
  This package re-exports the full framework-agnostic surface of
72
148
  [`@use-everywhere/core`](https://www.npmjs.com/package/@use-everywhere/core),
73
149
  so you never need to install core directly.
74
150
 
75
- Full docs, demo app (including a real cross-origin payment flow), and source:
76
- [github.com/rxova/use-everywhere](https://github.com/rxova/use-everywhere)
151
+ 📖 **[Documentation](https://rxova.github.io/use-everywhere/)** mental
152
+ model, how sync works, security model, recipes, and generated API reference.
153
+ Source and demo app: [github.com/rxova/use-everywhere](https://github.com/rxova/use-everywhere)
77
154
 
78
155
  ## License
79
156
 
package/dist/index.d.ts CHANGED
@@ -45,6 +45,27 @@ declare function useMessage<M extends MessageMap, K extends keyof M & string>(ch
45
45
  /** The channel's post function (stable identity per channel). */
46
46
  declare function useSend<M extends MessageMap>(channel: Channel<M>): Channel<M>['post'];
47
47
 
48
+ /** A channel bound to a name and message map: typed hooks with no per-call generics. */
49
+ interface ChannelHooks<M extends MessageMap> {
50
+ /** The underlying channel instance (the same one the hooks use) — for non-React code. */
51
+ get: () => Channel<M>;
52
+ /** The channel's post function (stable identity). */
53
+ useSend: () => Channel<M>['post'];
54
+ /**
55
+ * Subscribe to one message type. Same contract as the standalone
56
+ * `useMessage`: the handler is kept fresh without resubscribing.
57
+ */
58
+ useMessage: <K extends keyof M & string>(type: K, handler: (payload: M[K], meta: MessageMeta) => void) => void;
59
+ }
60
+
61
+ /**
62
+ * Bind a channel name and message map once, at module level, and get fully
63
+ * typed hooks back. Sugar over useChannel/useMessage/useSend — the same
64
+ * page-wide channel singleton is shared, so mixing bound and standalone
65
+ * hooks for one name is safe.
66
+ */
67
+ declare function defineChannel<M extends MessageMap>(name: string): ChannelHooks<M>;
68
+
48
69
  /** The other tabs/windows/workers currently alive on this origin. */
49
70
  declare function usePeers(options?: {
50
71
  name?: string;
@@ -74,4 +95,4 @@ interface UseOpenedWindow<Out extends MessageMap, In extends MessageMap, R> {
74
95
  */
75
96
  declare function useOpenedWindow<Out extends MessageMap, In extends MessageMap, R = unknown>(factory: () => OpenedWindow<Out, In, R>): UseOpenedWindow<Out, In, R>;
76
97
 
77
- export { type AnyStore, DEFAULT_NAME, type OpenedWindowStatus, type ShareScope, type UseOpenedWindow, type UseSharedStateOptions, getSharedStore, useChannel, useClientId, useMessage, useOpenedWindow, usePeers, useSend, useSharedState };
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 };
package/dist/index.js CHANGED
@@ -81,6 +81,17 @@ function useSend(channel) {
81
81
  return channel.post;
82
82
  }
83
83
 
84
+ // src/define-channel.ts
85
+ function defineChannel(name) {
86
+ const useBoundSend = () => useSend(useChannel(name));
87
+ const useBoundMessage = (type, handler) => useMessage(useChannel(name), type, handler);
88
+ return {
89
+ get: () => getChannel(name),
90
+ useSend: useBoundSend,
91
+ useMessage: useBoundMessage
92
+ };
93
+ }
94
+
84
95
  // src/use-peers.ts
85
96
  import { useCallback as useCallback2, useSyncExternalStore as useSyncExternalStore2 } from "react";
86
97
  var NO_PEERS = Object.freeze([]);
@@ -153,6 +164,7 @@ function useOpenedWindow(factory) {
153
164
  export * from "@use-everywhere/core";
154
165
  export {
155
166
  DEFAULT_NAME,
167
+ defineChannel,
156
168
  getSharedStore,
157
169
  useChannel,
158
170
  useClientId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "use-everywhere",
3
- "version": "0.1.0",
3
+ "version": "0.2.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>",
@@ -37,13 +37,46 @@
37
37
  "files": [
38
38
  "dist"
39
39
  ],
40
+ "size-limit": [
41
+ {
42
+ "name": "everything (import *)",
43
+ "path": "dist/index.js",
44
+ "import": "*",
45
+ "limit": "4.2 kB"
46
+ },
47
+ {
48
+ "name": "useSharedState",
49
+ "path": "dist/index.js",
50
+ "import": "{ useSharedState }",
51
+ "limit": "1.5 kB"
52
+ },
53
+ {
54
+ "name": "useChannel + useMessage + useSend",
55
+ "path": "dist/index.js",
56
+ "import": "{ useChannel, useMessage, useSend }",
57
+ "limit": "1 kB"
58
+ },
59
+ {
60
+ "name": "usePeers + useClientId",
61
+ "path": "dist/index.js",
62
+ "import": "{ usePeers, useClientId }",
63
+ "limit": "1.1 kB"
64
+ },
65
+ {
66
+ "name": "useOpenedWindow + openWindow",
67
+ "path": "dist/index.js",
68
+ "import": "{ useOpenedWindow, openWindow }",
69
+ "limit": "1.4 kB"
70
+ }
71
+ ],
40
72
  "dependencies": {
41
- "@use-everywhere/core": "0.1.0"
73
+ "@use-everywhere/core": "0.2.0"
42
74
  },
43
75
  "peerDependencies": {
44
76
  "react": ">=18"
45
77
  },
46
78
  "devDependencies": {
79
+ "@size-limit/preset-small-lib": "^12.1.0",
47
80
  "@testing-library/react": "^16.3.0",
48
81
  "@types/react": "^19.1.8",
49
82
  "@types/react-dom": "^19.2.3",
@@ -51,6 +84,7 @@
51
84
  "happy-dom": "^20.10.6",
52
85
  "react": "^19.1.0",
53
86
  "react-dom": "^19.1.0",
87
+ "size-limit": "^12.1.0",
54
88
  "tsup": "^8.5.0",
55
89
  "typescript": "^5.8.3",
56
90
  "vitest": "^4.1.10"
@@ -58,6 +92,7 @@
58
92
  "scripts": {
59
93
  "build": "tsup",
60
94
  "test": "vitest run --coverage",
61
- "typecheck": "tsc --noEmit"
95
+ "typecheck": "tsc --noEmit",
96
+ "size": "size-limit"
62
97
  }
63
98
  }