use-everywhere 0.1.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/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +164 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonatan Kruszewski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# use-everywhere
|
|
2
|
+
|
|
3
|
+
React hooks for state and messages that exist in every tab, window, and worker.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i use-everywhere
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Two transports behind one library:
|
|
10
|
+
|
|
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.
|
|
13
|
+
- **window.opener / postMessage** (cross-origin): a secure 1:1 channel to a
|
|
14
|
+
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
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```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'.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
On the opened (child) page:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { connectToOpener } from 'use-everywhere';
|
|
55
|
+
|
|
56
|
+
const conn = connectToOpener<ToPayment, FromPayment, Receipt>({
|
|
57
|
+
peerOrigin: 'https://shop.example.com',
|
|
58
|
+
});
|
|
59
|
+
conn.on('order', (order) => render(order));
|
|
60
|
+
conn.finish({ receiptId: 'r-123', last4: '4242' }); // resolves the opener's result
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Design notes
|
|
64
|
+
|
|
65
|
+
- **Shared state never crosses origins.** Two origins are two trust domains;
|
|
66
|
+
the cross-origin channel is explicit, per-message, and typed.
|
|
67
|
+
- **No Provider.** A BroadcastChannel is already global to the origin —
|
|
68
|
+
identity is the channel name, so hooks share module-level singletons.
|
|
69
|
+
- Values must survive structured clone (no functions, DOM nodes, etc.).
|
|
70
|
+
|
|
71
|
+
This package re-exports the full framework-agnostic surface of
|
|
72
|
+
[`@use-everywhere/core`](https://www.npmjs.com/package/@use-everywhere/core),
|
|
73
|
+
so you never need to install core directly.
|
|
74
|
+
|
|
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)
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { SharedStore, MessageMap, Channel, MessageMeta, Peer, OpenedWindow } from '@use-everywhere/core';
|
|
2
|
+
export * from '@use-everywhere/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How far a shared value travels:
|
|
6
|
+
* - 'everywhere' — every tab, window, and worker on this origin (default)
|
|
7
|
+
* - 'tabs' — tabs and windows only; writes coming from workers are ignored
|
|
8
|
+
* - 'tab' — this tab only (state is still shared between components)
|
|
9
|
+
*/
|
|
10
|
+
type ShareScope = 'everywhere' | 'tabs' | 'tab';
|
|
11
|
+
interface UseSharedStateOptions {
|
|
12
|
+
/** Store name; keys live in a namespace per store. Default 'use-everywhere'. */
|
|
13
|
+
store?: string;
|
|
14
|
+
/** How much to share. Default 'everywhere'. */
|
|
15
|
+
scope?: ShareScope;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Like useState, but the value exists in every tab, window, and worker on
|
|
20
|
+
* this origin. Late-joining tabs hydrate to the current value; concurrent
|
|
21
|
+
* writes converge last-writer-wins. Pass options.scope to delimit how much
|
|
22
|
+
* is shared ('everywhere' | 'tabs' | 'tab').
|
|
23
|
+
*/
|
|
24
|
+
declare function useSharedState<T>(key: string, initial: T, options?: UseSharedStateOptions): [T, (next: T | ((prev: T) => T)) => void];
|
|
25
|
+
|
|
26
|
+
/** Registry stores hold arbitrary keys — shape is decided by the hooks using them. */
|
|
27
|
+
type AnyStore = SharedStore<Record<string, unknown>>;
|
|
28
|
+
|
|
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
|
+
/** Imperative access to the store behind useSharedState (patch logs, non-React code). */
|
|
36
|
+
declare function getSharedStore(name?: string, scope?: ShareScope): AnyStore;
|
|
37
|
+
|
|
38
|
+
/** Get the page-wide typed channel for `name` (one instance per name). */
|
|
39
|
+
declare function useChannel<M extends MessageMap>(name: string): Channel<M>;
|
|
40
|
+
/**
|
|
41
|
+
* Subscribe to one message type. The handler is kept fresh without
|
|
42
|
+
* resubscribing, so it may close over render state.
|
|
43
|
+
*/
|
|
44
|
+
declare function useMessage<M extends MessageMap, K extends keyof M & string>(channel: Channel<M>, type: K, handler: (payload: M[K], meta: MessageMeta) => void): void;
|
|
45
|
+
/** The channel's post function (stable identity per channel). */
|
|
46
|
+
declare function useSend<M extends MessageMap>(channel: Channel<M>): Channel<M>['post'];
|
|
47
|
+
|
|
48
|
+
/** The other tabs/windows/workers currently alive on this origin. */
|
|
49
|
+
declare function usePeers(options?: {
|
|
50
|
+
name?: string;
|
|
51
|
+
}): readonly Peer[];
|
|
52
|
+
/** This client's own id on the presence bus (matches patch origin ids). */
|
|
53
|
+
declare function useClientId(options?: {
|
|
54
|
+
name?: string;
|
|
55
|
+
}): string;
|
|
56
|
+
|
|
57
|
+
type OpenedWindowStatus = 'idle' | 'opening' | 'connected' | 'done' | 'closed-early' | 'error';
|
|
58
|
+
interface UseOpenedWindow<Out extends MessageMap, In extends MessageMap, R> {
|
|
59
|
+
/** Call from a click handler (popup blockers require a user gesture). */
|
|
60
|
+
open: () => void;
|
|
61
|
+
status: OpenedWindowStatus;
|
|
62
|
+
/** The child's finish() value, once status is 'done'. */
|
|
63
|
+
result: R | undefined;
|
|
64
|
+
error: unknown;
|
|
65
|
+
/** Post to the child; no-op while nothing is open. */
|
|
66
|
+
post: OpenedWindow<Out, In, R>['post'];
|
|
67
|
+
close: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drive an openWindow() flow from a component: the factory is called on
|
|
72
|
+
* open(), and the child window's lifecycle is folded into render state.
|
|
73
|
+
* Reopening replaces the previous window; a stale window's outcome is ignored.
|
|
74
|
+
*/
|
|
75
|
+
declare function useOpenedWindow<Out extends MessageMap, In extends MessageMap, R = unknown>(factory: () => OpenedWindow<Out, In, R>): UseOpenedWindow<Out, In, R>;
|
|
76
|
+
|
|
77
|
+
export { type AnyStore, DEFAULT_NAME, type OpenedWindowStatus, type ShareScope, type UseOpenedWindow, type UseSharedStateOptions, getSharedStore, useChannel, useClientId, useMessage, useOpenedWindow, usePeers, useSend, useSharedState };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/use-shared-state.ts
|
|
2
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
// src/registry.ts
|
|
5
|
+
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
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/use-shared-state.ts
|
|
50
|
+
function useSharedState(key, initial, options) {
|
|
51
|
+
const store = getStore(options?.store ?? DEFAULT_NAME, options?.scope ?? "everywhere");
|
|
52
|
+
store.registerKey(key, initial);
|
|
53
|
+
const value = useSyncExternalStore(
|
|
54
|
+
useCallback((onChange) => store.subscribeKey(key, onChange), [store, key]),
|
|
55
|
+
() => store.getSnapshot()[key],
|
|
56
|
+
() => initial
|
|
57
|
+
);
|
|
58
|
+
const setValue = useCallback(
|
|
59
|
+
(next) => store.set(key, next),
|
|
60
|
+
[store, key]
|
|
61
|
+
);
|
|
62
|
+
return [value, setValue];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/use-message.ts
|
|
66
|
+
import { useEffect, useRef } from "react";
|
|
67
|
+
function useChannel(name) {
|
|
68
|
+
return getChannel(name);
|
|
69
|
+
}
|
|
70
|
+
function useMessage(channel, type, handler) {
|
|
71
|
+
const handlerRef = useRef(handler);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
handlerRef.current = handler;
|
|
74
|
+
});
|
|
75
|
+
useEffect(
|
|
76
|
+
() => channel.on(type, (payload, meta) => handlerRef.current(payload, meta)),
|
|
77
|
+
[channel, type]
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
function useSend(channel) {
|
|
81
|
+
return channel.post;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/use-peers.ts
|
|
85
|
+
import { useCallback as useCallback2, useSyncExternalStore as useSyncExternalStore2 } from "react";
|
|
86
|
+
var NO_PEERS = Object.freeze([]);
|
|
87
|
+
function usePeers(options) {
|
|
88
|
+
const presence = getPresence(options?.name ?? DEFAULT_NAME);
|
|
89
|
+
return useSyncExternalStore2(
|
|
90
|
+
useCallback2((onChange) => presence.subscribe(onChange), [presence]),
|
|
91
|
+
() => presence.getPeers(),
|
|
92
|
+
() => NO_PEERS
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
function useClientId(options) {
|
|
96
|
+
return getPresence(options?.name ?? DEFAULT_NAME).clientId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/use-opened-window.ts
|
|
100
|
+
import { useCallback as useCallback3, useRef as useRef2, useState } from "react";
|
|
101
|
+
import { WindowClosedError } from "@use-everywhere/core";
|
|
102
|
+
function useOpenedWindow(factory) {
|
|
103
|
+
const [status, setStatus] = useState("idle");
|
|
104
|
+
const [result, setResult] = useState(void 0);
|
|
105
|
+
const [error, setError] = useState(void 0);
|
|
106
|
+
const current = useRef2(null);
|
|
107
|
+
const factoryRef = useRef2(factory);
|
|
108
|
+
factoryRef.current = factory;
|
|
109
|
+
const open = useCallback3(() => {
|
|
110
|
+
current.current?.close();
|
|
111
|
+
let opened;
|
|
112
|
+
try {
|
|
113
|
+
opened = factoryRef.current();
|
|
114
|
+
} catch (err) {
|
|
115
|
+
setStatus("error");
|
|
116
|
+
setError(err);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
current.current = opened;
|
|
120
|
+
setStatus("opening");
|
|
121
|
+
setResult(void 0);
|
|
122
|
+
setError(void 0);
|
|
123
|
+
const fresh = () => current.current === opened;
|
|
124
|
+
opened.ready.then(
|
|
125
|
+
() => {
|
|
126
|
+
if (fresh()) setStatus((s) => s === "opening" ? "connected" : s);
|
|
127
|
+
},
|
|
128
|
+
() => {
|
|
129
|
+
}
|
|
130
|
+
// surfaced through result below
|
|
131
|
+
);
|
|
132
|
+
opened.result.then(
|
|
133
|
+
(value) => {
|
|
134
|
+
if (!fresh()) return;
|
|
135
|
+
setResult(value);
|
|
136
|
+
setStatus("done");
|
|
137
|
+
},
|
|
138
|
+
(err) => {
|
|
139
|
+
if (!fresh()) return;
|
|
140
|
+
setError(err);
|
|
141
|
+
setStatus(err instanceof WindowClosedError ? "closed-early" : "error");
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
}, []);
|
|
145
|
+
const post = useCallback3((type, payload) => {
|
|
146
|
+
current.current?.post(type, payload);
|
|
147
|
+
}, []);
|
|
148
|
+
const close = useCallback3(() => current.current?.close(), []);
|
|
149
|
+
return { open, status, result, error, post, close };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/index.ts
|
|
153
|
+
export * from "@use-everywhere/core";
|
|
154
|
+
export {
|
|
155
|
+
DEFAULT_NAME,
|
|
156
|
+
getSharedStore,
|
|
157
|
+
useChannel,
|
|
158
|
+
useClientId,
|
|
159
|
+
useMessage,
|
|
160
|
+
useOpenedWindow,
|
|
161
|
+
usePeers,
|
|
162
|
+
useSend,
|
|
163
|
+
useSharedState
|
|
164
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "use-everywhere",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React hooks for state and messages shared across tabs, windows, and workers",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Jonatan Kruszewski <jonakrusze@gmail.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/rxova/use-everywhere.git",
|
|
10
|
+
"directory": "packages/react"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/rxova/use-everywhere#readme",
|
|
13
|
+
"bugs": "https://github.com/rxova/use-everywhere/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"react",
|
|
16
|
+
"hooks",
|
|
17
|
+
"broadcastchannel",
|
|
18
|
+
"cross-tab",
|
|
19
|
+
"shared-state",
|
|
20
|
+
"postmessage",
|
|
21
|
+
"cross-origin",
|
|
22
|
+
"presence"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@use-everywhere/core": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@testing-library/react": "^16.3.0",
|
|
48
|
+
"@types/react": "^19.1.8",
|
|
49
|
+
"@types/react-dom": "^19.2.3",
|
|
50
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
51
|
+
"happy-dom": "^20.10.6",
|
|
52
|
+
"react": "^19.1.0",
|
|
53
|
+
"react-dom": "^19.1.0",
|
|
54
|
+
"tsup": "^8.5.0",
|
|
55
|
+
"typescript": "^5.8.3",
|
|
56
|
+
"vitest": "^4.1.10"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsup",
|
|
60
|
+
"test": "vitest run --coverage",
|
|
61
|
+
"typecheck": "tsc --noEmit"
|
|
62
|
+
}
|
|
63
|
+
}
|