@kontsedal/olas-cross-tab 0.0.1-rc.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 +122 -0
- package/dist/index.cjs +199 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +115 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +115 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +196 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/channel.ts +41 -0
- package/src/index.ts +14 -0
- package/src/plugin.ts +214 -0
- package/src/protocol.ts +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bohdan
|
|
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,122 @@
|
|
|
1
|
+
# @kontsedal/olas-cross-tab
|
|
2
|
+
|
|
3
|
+
`BroadcastChannel`-backed cache sync for `@kontsedal/olas-core`. When one tab writes via `query.setData(...)` or `query.invalidate(...)`, every other tab of the same origin sees the same write — without re-fetching, without persistence, without a server round-trip. SPEC §13.2.
|
|
4
|
+
|
|
5
|
+
This is the **in-memory** sibling to `@kontsedal/olas-persist`. Persistence mirrors *durable* state on the `storage` event; this mirrors the (much larger) in-memory query cache that never touches disk. Both are independently opt-in.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @kontsedal/olas-cross-tab @kontsedal/olas-core @preact/signals-core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 30-second example
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createRoot, defineController, defineQuery } from '@kontsedal/olas-core'
|
|
17
|
+
import { crossTabPlugin } from '@kontsedal/olas-cross-tab'
|
|
18
|
+
|
|
19
|
+
// Opt the query in. `queryId` is required — it routes inbound messages
|
|
20
|
+
// across tabs without depending on the in-memory `Query` reference.
|
|
21
|
+
const userQuery = defineQuery({
|
|
22
|
+
queryId: 'app/user/v1',
|
|
23
|
+
crossTab: true,
|
|
24
|
+
key: (id: string) => ['user', id],
|
|
25
|
+
fetcher: (_ctx, id: string) => fetch(`/api/user/${id}`).then((r) => r.json()),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const appController = defineController((ctx) => {
|
|
29
|
+
const user = ctx.use(userQuery, () => ['me' as string])
|
|
30
|
+
return { user }
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const root = createRoot(appController, {
|
|
34
|
+
deps: {},
|
|
35
|
+
plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Tab A calls `userQuery.setData('me', (prev) => ({ ...prev, name: 'New' }))` — Tab B's subscribers see the new value on the next signal flush. No fetch fires in Tab B.
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin
|
|
45
|
+
|
|
46
|
+
type CrossTabOptions = {
|
|
47
|
+
channelName: string
|
|
48
|
+
onWarn?: (message: string, cause?: unknown) => void
|
|
49
|
+
channelFactory?: (name: string) => ChannelLike | undefined
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Option | Default | What |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `channelName` | required | Name of the `BroadcastChannel`. Include a version suffix (`my-app/v2`) for clean cross-deploy isolation — receivers from a different deploy with a different channel name simply don't see each other's traffic. |
|
|
56
|
+
| `onWarn` | `console.warn` | Called on non-fatal conditions: `DataCloneError` while broadcasting (the data isn't structured-cloneable) or a malformed inbound message. |
|
|
57
|
+
| `channelFactory` | `defaultChannelFactory` (wraps `BroadcastChannel`) | Override the channel constructor. Mainly for tests. Return `undefined` to disable cross-tab (the plugin becomes a no-op). |
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
Every `setData` or `invalidate` on a `crossTab: true` query fires a `QueryClientPlugin` event (§13.2). This plugin posts the event onto a `BroadcastChannel`. Receiving tabs replay the write via the plugin api's `applyRemoteSetData` / `applyRemoteInvalidate` — both flagged `isRemote: true`, so the receiving tab's plugin doesn't echo back.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
Tab A: query.setData(...) → QueryClient.setData → plugin.onSetData (isRemote: false)
|
|
65
|
+
↓
|
|
66
|
+
channel.postMessage(msg)
|
|
67
|
+
↓
|
|
68
|
+
━━━━━━━━━━━━━━━━━━━ BroadcastChannel ━━━━━━━━━━━━━━━━━━━
|
|
69
|
+
↓
|
|
70
|
+
Tab B: api.applyRemoteSetData(...) ← channel listener ← msg
|
|
71
|
+
QueryClient.setData → plugin.onSetData (isRemote: true) → no rebroadcast
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Echo prevention (three layers)
|
|
75
|
+
|
|
76
|
+
1. **Sender-side:** the plugin skips outbound broadcasts when `SetDataEvent.isRemote === true` (the write was triggered by an inbound message).
|
|
77
|
+
2. **Own-source drop:** receivers filter messages by `sourceId` — every plugin instance picks a random one at construction. If the transport echoes the message back, the sender ignores it.
|
|
78
|
+
3. **`(sourceId, msgId)` dedup:** monotonic `msgId` per `sourceId` lets receivers drop out-of-order or duplicate messages.
|
|
79
|
+
|
|
80
|
+
### Protocol versioning
|
|
81
|
+
|
|
82
|
+
Messages carry `v: PROTOCOL_VERSION`. Receivers drop messages with a `v` they don't understand. Channel names themselves are user-supplied; for cross-deploy isolation, embed a version in your `channelName` (e.g. `'app/cache/v2'`).
|
|
83
|
+
|
|
84
|
+
### Non-cloneable data
|
|
85
|
+
|
|
86
|
+
`BroadcastChannel` uses structured clone. Cache data containing functions, class instances, or symbols throws `DataCloneError` at `postMessage`. The plugin catches the throw, calls `onWarn(...)`, and drops the message. **The sender's cache is unaffected** — only the cross-tab echo is lost.
|
|
87
|
+
|
|
88
|
+
## Per-query opt-in
|
|
89
|
+
|
|
90
|
+
Two fields on the spec gate cross-tab behavior:
|
|
91
|
+
|
|
92
|
+
- **`queryId: string`** — required. Stable name routed across tabs. Don't auto-derive from `fetcher.name` (fragile under minification) or argument hashing.
|
|
93
|
+
- **`crossTab: true`** — flips the per-query gate. Without it, the plugin doesn't broadcast (so module-internal queries don't leak).
|
|
94
|
+
|
|
95
|
+
Setting `crossTab: true` without a `queryId` logs a one-time `console.warn` (dev only) and disables sync for that query.
|
|
96
|
+
|
|
97
|
+
## SSR
|
|
98
|
+
|
|
99
|
+
When `BroadcastChannel === undefined` (Node, older browsers) and no `channelFactory` override is supplied, `crossTabPlugin(...)` returns a no-op plugin. The root still constructs cleanly; cross-tab is just disabled. This means you can wire the plugin unconditionally in shared code paths.
|
|
100
|
+
|
|
101
|
+
## Interaction with `@kontsedal/olas-persist`
|
|
102
|
+
|
|
103
|
+
These two layers solve different problems:
|
|
104
|
+
|
|
105
|
+
- `@kontsedal/olas-persist` mirrors **durable** state via `localStorage` + the `storage` event.
|
|
106
|
+
- `@kontsedal/olas-cross-tab` mirrors the **in-memory** query cache via `BroadcastChannel`.
|
|
107
|
+
|
|
108
|
+
You can combine them on the same logical entity, but it's redundant — `@kontsedal/olas-persist`'s cross-tab sync already covers the durable copy.
|
|
109
|
+
|
|
110
|
+
## Limitations (v1)
|
|
111
|
+
|
|
112
|
+
- **No infinite queries.** `defineInfiniteQuery` syncs are intentionally skipped — the page-array payload is too heavy to be a safe default. Plugin events fire with `kind: 'infinite'` for forward compatibility; this plugin filters them out.
|
|
113
|
+
- **No structural diffs.** Every `setData` broadcasts the full post-update value. For chunky cache entries this is fine because `BroadcastChannel` is in-memory; for very large arrays it's a known cost.
|
|
114
|
+
- **No pending-mutation arbitration.** If two tabs run optimistic mutations on the same entry concurrently, the last `setData` to arrive wins on both sides. Your mutation `onError` / `onSuccess` then re-syncs from the server, which restores convergence at the cost of a temporary divergence.
|
|
115
|
+
- **Optimistic writes cross tabs.** `setData` events fire regardless of cause, so optimistic state (and any rollback) is visible cross-tab. If you need optimistic UI to stay local, gate the write yourself.
|
|
116
|
+
|
|
117
|
+
## Further reading
|
|
118
|
+
|
|
119
|
+
- [`../../.wiki/modules/cross-tab.md`](../../.wiki/modules/cross-tab.md)
|
|
120
|
+
- SPEC §13.2 — Cross-tab in-memory cache sync.
|
|
121
|
+
- SPEC §5.2 — Query definition (`queryId`, `crossTab`).
|
|
122
|
+
- SPEC §20.8 — `RootOptions.plugins`.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _kontsedal_olas_core = require("@kontsedal/olas-core");
|
|
3
|
+
//#region src/channel.ts
|
|
4
|
+
/**
|
|
5
|
+
* Default factory: wraps the platform `BroadcastChannel`. Returns
|
|
6
|
+
* `undefined` when `BroadcastChannel` is not defined (SSR / Node without
|
|
7
|
+
* `--experimental-broadcastchannel`, older browsers).
|
|
8
|
+
*/
|
|
9
|
+
function defaultChannelFactory(name) {
|
|
10
|
+
if (typeof BroadcastChannel === "undefined") return void 0;
|
|
11
|
+
const ch = new BroadcastChannel(name);
|
|
12
|
+
return {
|
|
13
|
+
postMessage(data) {
|
|
14
|
+
ch.postMessage(data);
|
|
15
|
+
},
|
|
16
|
+
addEventListener(type, listener) {
|
|
17
|
+
ch.addEventListener(type, listener);
|
|
18
|
+
},
|
|
19
|
+
removeEventListener(type, listener) {
|
|
20
|
+
ch.removeEventListener(type, listener);
|
|
21
|
+
},
|
|
22
|
+
close() {
|
|
23
|
+
ch.close();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/protocol.ts
|
|
29
|
+
/**
|
|
30
|
+
* Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.
|
|
31
|
+
*
|
|
32
|
+
* `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)
|
|
33
|
+
* combine to make the three-layer echo prevention work:
|
|
34
|
+
*
|
|
35
|
+
* 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the
|
|
36
|
+
* write originated from `applyRemoteSetData`).
|
|
37
|
+
* 2. Receiver filters its own `sourceId` (catches the case where the
|
|
38
|
+
* transport echoes the message back to the sender).
|
|
39
|
+
* 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order
|
|
40
|
+
* messages from the same peer are dropped.
|
|
41
|
+
*
|
|
42
|
+
* Receivers also drop messages with a `v` they don't understand. The
|
|
43
|
+
* channel name itself is user-supplied; consumers who want clean
|
|
44
|
+
* cross-deploy isolation should embed a version in their `channelName`.
|
|
45
|
+
*/
|
|
46
|
+
const PROTOCOL_VERSION = 1;
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/plugin.ts
|
|
49
|
+
/**
|
|
50
|
+
* Generate a unique-enough source id for a plugin instance. Combines
|
|
51
|
+
* `Date.now()` with `Math.random()` — collisions across same-millisecond
|
|
52
|
+
* tab-opens are negligible at one-decimal-place randomness, and even a
|
|
53
|
+
* collision only loses dedup, not correctness.
|
|
54
|
+
*/
|
|
55
|
+
function makeSourceId() {
|
|
56
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
57
|
+
return `${Date.now().toString(36)}-${rand}`;
|
|
58
|
+
}
|
|
59
|
+
const NOOP_PLUGIN = {};
|
|
60
|
+
/**
|
|
61
|
+
* Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /
|
|
62
|
+
* `invalidate` writes across tabs of the same origin.
|
|
63
|
+
*
|
|
64
|
+
* Wire it up via `RootOptions.plugins`:
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* createRoot(appController, {
|
|
68
|
+
* deps,
|
|
69
|
+
* plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],
|
|
70
|
+
* })
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`
|
|
74
|
+
* — the `queryId` routes inbound messages back to the right query, and
|
|
75
|
+
* `crossTab: true` is the per-query opt-in (queries that don't set it are
|
|
76
|
+
* ignored by the sender so module-internal queries don't leak).
|
|
77
|
+
*
|
|
78
|
+
* **SSR safety.** When `BroadcastChannel` is not defined (Node, older
|
|
79
|
+
* browsers without the feature) and no `channelFactory` override is
|
|
80
|
+
* supplied, the function returns a no-op plugin object. The root still
|
|
81
|
+
* boots cleanly; cross-tab is just disabled.
|
|
82
|
+
*
|
|
83
|
+
* **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache
|
|
84
|
+
* data containing functions, class instances, or symbols throws a
|
|
85
|
+
* `DataCloneError` at `postMessage`. The plugin catches the throw, calls
|
|
86
|
+
* `onWarn(...)`, and drops the message — the sender's cache is unaffected.
|
|
87
|
+
*/
|
|
88
|
+
function crossTabPlugin(options) {
|
|
89
|
+
const channelName = options.channelName;
|
|
90
|
+
const onWarn = options.onWarn ?? defaultWarn;
|
|
91
|
+
const factory = options.channelFactory ?? defaultChannelFactory;
|
|
92
|
+
const probe = factory(channelName);
|
|
93
|
+
if (!probe) return NOOP_PLUGIN;
|
|
94
|
+
probe.close();
|
|
95
|
+
const sourceId = makeSourceId();
|
|
96
|
+
let msgIdCounter = 0;
|
|
97
|
+
const seenByPeer = /* @__PURE__ */ new Map();
|
|
98
|
+
let api = null;
|
|
99
|
+
let channel = null;
|
|
100
|
+
const listener = (event) => {
|
|
101
|
+
const msg = event.data;
|
|
102
|
+
if (!msg || typeof msg !== "object") return;
|
|
103
|
+
if (msg.v !== 1) return;
|
|
104
|
+
if (msg.sourceId === sourceId) return;
|
|
105
|
+
if (typeof msg.sourceId !== "string" || typeof msg.msgId !== "number") return;
|
|
106
|
+
const last = seenByPeer.get(msg.sourceId) ?? -1;
|
|
107
|
+
if (msg.msgId <= last) return;
|
|
108
|
+
seenByPeer.set(msg.sourceId, msg.msgId);
|
|
109
|
+
if (msg.type === "setData") {
|
|
110
|
+
if (typeof msg.queryId !== "string" || !Array.isArray(msg.keyArgs)) {
|
|
111
|
+
onWarn("[olas/cross-tab] malformed setData message");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
api?.applyRemoteSetData(msg.queryId, msg.keyArgs, msg.data);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (msg.type === "invalidate") {
|
|
118
|
+
if (typeof msg.queryId !== "string" || !Array.isArray(msg.keyArgs)) {
|
|
119
|
+
onWarn("[olas/cross-tab] malformed invalidate message");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
api?.applyRemoteInvalidate(msg.queryId, msg.keyArgs);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const send = (msg) => {
|
|
127
|
+
if (channel === null) return;
|
|
128
|
+
try {
|
|
129
|
+
channel.postMessage(msg);
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
onWarn(`[olas/cross-tab] failed to broadcast ${msg.type} for queryId="${msg.queryId}": data is not structured-cloneable`, cause);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
init(a) {
|
|
136
|
+
api = a;
|
|
137
|
+
channel = factory(channelName) ?? null;
|
|
138
|
+
channel?.addEventListener("message", listener);
|
|
139
|
+
},
|
|
140
|
+
onSetData(event) {
|
|
141
|
+
if (event.isRemote) return;
|
|
142
|
+
if (event.kind !== "data") return;
|
|
143
|
+
if (!shouldBroadcast(event.queryId)) return;
|
|
144
|
+
send({
|
|
145
|
+
v: 1,
|
|
146
|
+
type: "setData",
|
|
147
|
+
sourceId,
|
|
148
|
+
msgId: ++msgIdCounter,
|
|
149
|
+
queryId: event.queryId,
|
|
150
|
+
keyArgs: event.keyArgs,
|
|
151
|
+
data: event.data
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
onInvalidate(event) {
|
|
155
|
+
if (event.isRemote) return;
|
|
156
|
+
if (event.kind !== "data") return;
|
|
157
|
+
if (!shouldBroadcast(event.queryId)) return;
|
|
158
|
+
send({
|
|
159
|
+
v: 1,
|
|
160
|
+
type: "invalidate",
|
|
161
|
+
sourceId,
|
|
162
|
+
msgId: ++msgIdCounter,
|
|
163
|
+
queryId: event.queryId,
|
|
164
|
+
keyArgs: event.keyArgs
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
onGc(_event) {},
|
|
168
|
+
dispose() {
|
|
169
|
+
if (channel !== null) {
|
|
170
|
+
channel.removeEventListener("message", listener);
|
|
171
|
+
channel.close();
|
|
172
|
+
channel = null;
|
|
173
|
+
}
|
|
174
|
+
api = null;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function defaultWarn(message, cause) {
|
|
179
|
+
if (cause !== void 0) console.warn(message, cause);
|
|
180
|
+
else console.warn(message);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Per-query gate. `crossTab: true` is a static opt-in on the spec; the
|
|
184
|
+
* QueryClient doesn't filter on it (its events fire for every query that
|
|
185
|
+
* has a `queryId`), so the plugin checks here. We look it up from the
|
|
186
|
+
* core's query registry on every event — no caching, since the registry
|
|
187
|
+
* lookup is a Map.get.
|
|
188
|
+
*/
|
|
189
|
+
function shouldBroadcast(queryId) {
|
|
190
|
+
const registered = (0, _kontsedal_olas_core.lookupRegisteredQuery)(queryId);
|
|
191
|
+
if (!registered) return false;
|
|
192
|
+
return registered.__spec.crossTab === true;
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
exports.PROTOCOL_VERSION = PROTOCOL_VERSION;
|
|
196
|
+
exports.crossTabPlugin = crossTabPlugin;
|
|
197
|
+
exports.defaultChannelFactory = defaultChannelFactory;
|
|
198
|
+
|
|
199
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../src/channel.ts","../src/protocol.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Tiny `BroadcastChannel`-shaped abstraction. Lets tests inject a fake\n * (a shared in-memory bus across multiple \"tabs\" in the same process) and\n * keeps SSR-safety in one place — when `BroadcastChannel` is absent and\n * no `channelFactory` override is supplied, the plugin returns a no-op\n * variant up the stack.\n */\n\nexport type ChannelLike = {\n postMessage(data: unknown): void\n addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n close(): void\n}\n\n/**\n * Default factory: wraps the platform `BroadcastChannel`. Returns\n * `undefined` when `BroadcastChannel` is not defined (SSR / Node without\n * `--experimental-broadcastchannel`, older browsers).\n */\nexport function defaultChannelFactory(name: string): ChannelLike | undefined {\n if (typeof BroadcastChannel === 'undefined') return undefined\n const ch = new BroadcastChannel(name)\n return {\n postMessage(data) {\n ch.postMessage(data)\n },\n addEventListener(type, listener) {\n // The platform BroadcastChannel typing wants a `MessageEvent`\n // listener, but we only care about `event.data` — cast through\n // `unknown` since the shapes don't structurally overlap.\n ch.addEventListener(type, listener as unknown as EventListener)\n },\n removeEventListener(type, listener) {\n ch.removeEventListener(type, listener as unknown as EventListener)\n },\n close() {\n ch.close()\n },\n }\n}\n","/**\n * Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.\n *\n * `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)\n * combine to make the three-layer echo prevention work:\n *\n * 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the\n * write originated from `applyRemoteSetData`).\n * 2. Receiver filters its own `sourceId` (catches the case where the\n * transport echoes the message back to the sender).\n * 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order\n * messages from the same peer are dropped.\n *\n * Receivers also drop messages with a `v` they don't understand. The\n * channel name itself is user-supplied; consumers who want clean\n * cross-deploy isolation should embed a version in their `channelName`.\n */\n\nexport const PROTOCOL_VERSION = 1\n\nexport type SetDataMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'setData'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n data: unknown\n}\n\nexport type InvalidateMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'invalidate'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n}\n\nexport type Message = SetDataMessage | InvalidateMessage\n","import {\n type GcEvent,\n type InvalidateEvent,\n lookupRegisteredQuery,\n type QueryClientPlugin,\n type QueryClientPluginApi,\n type SetDataEvent,\n} from '@kontsedal/olas-core'\nimport { type ChannelLike, defaultChannelFactory } from './channel'\nimport { type Message, PROTOCOL_VERSION } from './protocol'\n\n/**\n * Options accepted by `crossTabPlugin(...)`. SPEC §13.2.\n *\n * - `channelName` — name of the `BroadcastChannel`. Required. Users who\n * want clean cross-deploy isolation should include a version suffix\n * (e.g. `'my-app/cache/v2'`).\n * - `onWarn` — called for non-fatal conditions: a `DataCloneError` while\n * posting (the data isn't structured-cloneable), or a malformed\n * inbound message. Default: `console.warn`.\n * - `channelFactory` — override the channel constructor. Mainly for\n * tests that share an in-memory bus across two QueryClients.\n */\nexport type CrossTabOptions = {\n channelName: string\n onWarn?: (message: string, cause?: unknown) => void\n channelFactory?: (name: string) => ChannelLike | undefined\n}\n\n/**\n * Generate a unique-enough source id for a plugin instance. Combines\n * `Date.now()` with `Math.random()` — collisions across same-millisecond\n * tab-opens are negligible at one-decimal-place randomness, and even a\n * collision only loses dedup, not correctness.\n */\nfunction makeSourceId(): string {\n const rand = Math.random().toString(36).slice(2, 10)\n return `${Date.now().toString(36)}-${rand}`\n}\n\nconst NOOP_PLUGIN: QueryClientPlugin = {}\n\n/**\n * Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /\n * `invalidate` writes across tabs of the same origin.\n *\n * Wire it up via `RootOptions.plugins`:\n *\n * ```ts\n * createRoot(appController, {\n * deps,\n * plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],\n * })\n * ```\n *\n * Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`\n * — the `queryId` routes inbound messages back to the right query, and\n * `crossTab: true` is the per-query opt-in (queries that don't set it are\n * ignored by the sender so module-internal queries don't leak).\n *\n * **SSR safety.** When `BroadcastChannel` is not defined (Node, older\n * browsers without the feature) and no `channelFactory` override is\n * supplied, the function returns a no-op plugin object. The root still\n * boots cleanly; cross-tab is just disabled.\n *\n * **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache\n * data containing functions, class instances, or symbols throws a\n * `DataCloneError` at `postMessage`. The plugin catches the throw, calls\n * `onWarn(...)`, and drops the message — the sender's cache is unaffected.\n */\nexport function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {\n const channelName = options.channelName\n const onWarn = options.onWarn ?? defaultWarn\n const factory = options.channelFactory ?? defaultChannelFactory\n\n // Cheap probe — if the environment can't produce a channel at all we can\n // return a no-op plugin without opening anything. The real channel opens\n // lazily in `init` so a plugin that's constructed but never passed to a\n // root doesn't leak a BroadcastChannel.\n const probe = factory(channelName)\n if (!probe) {\n // SSR / unsupported environment. Caller's plugin slot still receives a\n // valid plugin object; it just does nothing.\n return NOOP_PLUGIN\n }\n probe.close()\n\n const sourceId = makeSourceId()\n let msgIdCounter = 0\n const seenByPeer = new Map<string, number>()\n let api: QueryClientPluginApi | null = null\n let channel: ChannelLike | null = null\n\n const listener = (event: { data: unknown }) => {\n const msg = event.data as Partial<Message> | null\n if (!msg || typeof msg !== 'object') return\n // Layer 1 — protocol version drop.\n if (msg.v !== PROTOCOL_VERSION) return\n // Layer 2 — own-source drop (transport echoed our own message back).\n if (msg.sourceId === sourceId) return\n // Layer 3 — out-of-order / duplicate drop.\n if (typeof msg.sourceId !== 'string' || typeof msg.msgId !== 'number') return\n const last = seenByPeer.get(msg.sourceId) ?? -1\n if (msg.msgId <= last) return\n seenByPeer.set(msg.sourceId, msg.msgId)\n\n if (msg.type === 'setData') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed setData message')\n return\n }\n api?.applyRemoteSetData(msg.queryId, msg.keyArgs, msg.data)\n return\n }\n if (msg.type === 'invalidate') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed invalidate message')\n return\n }\n api?.applyRemoteInvalidate(msg.queryId, msg.keyArgs)\n return\n }\n }\n\n const send = (msg: Message): void => {\n if (channel === null) return\n try {\n channel.postMessage(msg)\n } catch (cause) {\n // Structured clone failed — most likely non-cloneable data on a\n // setData payload. Warn and drop.\n onWarn(\n `[olas/cross-tab] failed to broadcast ${msg.type} for queryId=\"${msg.queryId}\": data is not structured-cloneable`,\n cause,\n )\n }\n }\n\n return {\n init(a) {\n api = a\n channel = factory(channelName) ?? null\n channel?.addEventListener('message', listener)\n },\n\n onSetData(event: SetDataEvent) {\n // Don't echo inbound writes — Layer-1 sender-side echo prevention.\n if (event.isRemote) return\n // Infinite queries are deferred for v1 — see SPEC §13.2.\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'setData',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n data: event.data,\n })\n },\n\n onInvalidate(event: InvalidateEvent) {\n if (event.isRemote) return\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'invalidate',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n })\n },\n\n onGc(_event: GcEvent) {\n // GC is local — we don't propagate it. Each tab gc's its own entries\n // when its own subscribers drop.\n },\n\n dispose() {\n if (channel !== null) {\n channel.removeEventListener('message', listener)\n channel.close()\n channel = null\n }\n api = null\n },\n }\n}\n\nfunction defaultWarn(message: string, cause?: unknown): void {\n if (cause !== undefined) {\n console.warn(message, cause)\n } else {\n console.warn(message)\n }\n}\n\n/**\n * Per-query gate. `crossTab: true` is a static opt-in on the spec; the\n * QueryClient doesn't filter on it (its events fire for every query that\n * has a `queryId`), so the plugin checks here. We look it up from the\n * core's query registry on every event — no caching, since the registry\n * lookup is a Map.get.\n */\nfunction shouldBroadcast(queryId: string): boolean {\n const registered = lookupRegisteredQuery(queryId)\n if (!registered) return false\n return registered.__spec.crossTab === true\n}\n"],"mappings":"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"}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { QueryClientPlugin } from "@kontsedal/olas-core";
|
|
2
|
+
|
|
3
|
+
//#region src/channel.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Tiny `BroadcastChannel`-shaped abstraction. Lets tests inject a fake
|
|
6
|
+
* (a shared in-memory bus across multiple "tabs" in the same process) and
|
|
7
|
+
* keeps SSR-safety in one place — when `BroadcastChannel` is absent and
|
|
8
|
+
* no `channelFactory` override is supplied, the plugin returns a no-op
|
|
9
|
+
* variant up the stack.
|
|
10
|
+
*/
|
|
11
|
+
type ChannelLike = {
|
|
12
|
+
postMessage(data: unknown): void;
|
|
13
|
+
addEventListener(type: 'message', listener: (event: {
|
|
14
|
+
data: unknown;
|
|
15
|
+
}) => void): void;
|
|
16
|
+
removeEventListener(type: 'message', listener: (event: {
|
|
17
|
+
data: unknown;
|
|
18
|
+
}) => void): void;
|
|
19
|
+
close(): void;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Default factory: wraps the platform `BroadcastChannel`. Returns
|
|
23
|
+
* `undefined` when `BroadcastChannel` is not defined (SSR / Node without
|
|
24
|
+
* `--experimental-broadcastchannel`, older browsers).
|
|
25
|
+
*/
|
|
26
|
+
declare function defaultChannelFactory(name: string): ChannelLike | undefined;
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/plugin.d.ts
|
|
29
|
+
/**
|
|
30
|
+
* Options accepted by `crossTabPlugin(...)`. SPEC §13.2.
|
|
31
|
+
*
|
|
32
|
+
* - `channelName` — name of the `BroadcastChannel`. Required. Users who
|
|
33
|
+
* want clean cross-deploy isolation should include a version suffix
|
|
34
|
+
* (e.g. `'my-app/cache/v2'`).
|
|
35
|
+
* - `onWarn` — called for non-fatal conditions: a `DataCloneError` while
|
|
36
|
+
* posting (the data isn't structured-cloneable), or a malformed
|
|
37
|
+
* inbound message. Default: `console.warn`.
|
|
38
|
+
* - `channelFactory` — override the channel constructor. Mainly for
|
|
39
|
+
* tests that share an in-memory bus across two QueryClients.
|
|
40
|
+
*/
|
|
41
|
+
type CrossTabOptions = {
|
|
42
|
+
channelName: string;
|
|
43
|
+
onWarn?: (message: string, cause?: unknown) => void;
|
|
44
|
+
channelFactory?: (name: string) => ChannelLike | undefined;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /
|
|
48
|
+
* `invalidate` writes across tabs of the same origin.
|
|
49
|
+
*
|
|
50
|
+
* Wire it up via `RootOptions.plugins`:
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* createRoot(appController, {
|
|
54
|
+
* deps,
|
|
55
|
+
* plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],
|
|
56
|
+
* })
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`
|
|
60
|
+
* — the `queryId` routes inbound messages back to the right query, and
|
|
61
|
+
* `crossTab: true` is the per-query opt-in (queries that don't set it are
|
|
62
|
+
* ignored by the sender so module-internal queries don't leak).
|
|
63
|
+
*
|
|
64
|
+
* **SSR safety.** When `BroadcastChannel` is not defined (Node, older
|
|
65
|
+
* browsers without the feature) and no `channelFactory` override is
|
|
66
|
+
* supplied, the function returns a no-op plugin object. The root still
|
|
67
|
+
* boots cleanly; cross-tab is just disabled.
|
|
68
|
+
*
|
|
69
|
+
* **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache
|
|
70
|
+
* data containing functions, class instances, or symbols throws a
|
|
71
|
+
* `DataCloneError` at `postMessage`. The plugin catches the throw, calls
|
|
72
|
+
* `onWarn(...)`, and drops the message — the sender's cache is unaffected.
|
|
73
|
+
*/
|
|
74
|
+
declare function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin;
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/protocol.d.ts
|
|
77
|
+
/**
|
|
78
|
+
* Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.
|
|
79
|
+
*
|
|
80
|
+
* `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)
|
|
81
|
+
* combine to make the three-layer echo prevention work:
|
|
82
|
+
*
|
|
83
|
+
* 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the
|
|
84
|
+
* write originated from `applyRemoteSetData`).
|
|
85
|
+
* 2. Receiver filters its own `sourceId` (catches the case where the
|
|
86
|
+
* transport echoes the message back to the sender).
|
|
87
|
+
* 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order
|
|
88
|
+
* messages from the same peer are dropped.
|
|
89
|
+
*
|
|
90
|
+
* Receivers also drop messages with a `v` they don't understand. The
|
|
91
|
+
* channel name itself is user-supplied; consumers who want clean
|
|
92
|
+
* cross-deploy isolation should embed a version in their `channelName`.
|
|
93
|
+
*/
|
|
94
|
+
declare const PROTOCOL_VERSION = 1;
|
|
95
|
+
type SetDataMessage = {
|
|
96
|
+
v: typeof PROTOCOL_VERSION;
|
|
97
|
+
type: 'setData';
|
|
98
|
+
sourceId: string;
|
|
99
|
+
msgId: number;
|
|
100
|
+
queryId: string;
|
|
101
|
+
keyArgs: readonly unknown[];
|
|
102
|
+
data: unknown;
|
|
103
|
+
};
|
|
104
|
+
type InvalidateMessage = {
|
|
105
|
+
v: typeof PROTOCOL_VERSION;
|
|
106
|
+
type: 'invalidate';
|
|
107
|
+
sourceId: string;
|
|
108
|
+
msgId: number;
|
|
109
|
+
queryId: string;
|
|
110
|
+
keyArgs: readonly unknown[];
|
|
111
|
+
};
|
|
112
|
+
type Message = SetDataMessage | InvalidateMessage;
|
|
113
|
+
//#endregion
|
|
114
|
+
export { type ChannelLike, type CrossTabOptions, type InvalidateMessage, type Message, PROTOCOL_VERSION, type SetDataMessage, crossTabPlugin, defaultChannelFactory };
|
|
115
|
+
//# sourceMappingURL=index.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../src/channel.ts","../src/plugin.ts","../src/protocol.ts"],"mappings":";;;;;;AAQA;;;;KAAY,WAAA;EACV,WAAA,CAAY,IAAA;EACZ,gBAAA,CAAiB,IAAA,aAAiB,QAAA,GAAW,KAAA;IAAS,IAAA;EAAA;EACtD,mBAAA,CAAoB,IAAA,aAAiB,QAAA,GAAW,KAAA;IAAS,IAAA;EAAA;EACzD,KAAA;AAAA;;;;;AAAK;iBAQS,qBAAA,CAAsB,IAAA,WAAe,WAAW;;;;AAZhE;;;;;;;;;;;KCeY,eAAA;EACV,WAAA;EACA,MAAA,IAAU,OAAA,UAAiB,KAAA;EAC3B,cAAA,IAAkB,IAAA,aAAiB,WAAW;AAAA;;;ADdzC;AAQP;;;;AAAgE;;;;ACGhE;;;;;;;;;;;;AAGgD;AA4ChD;;;;iBAAgB,cAAA,CAAe,OAAA,EAAS,eAAA,GAAkB,iBAAiB;;;;;;AD9D3E;;;;;;;;;;;;;;cEUa,gBAAA;AAAA,KAED,cAAA;EACV,CAAA,SAAU,gBAAgB;EAC1B,IAAA;EACA,QAAA;EACA,KAAA;EACA,OAAA;EACA,OAAA;EACA,IAAA;AAAA;AAAA,KAGU,iBAAA;EACV,CAAA,SAAU,gBAAgB;EAC1B,IAAA;EACA,QAAA;EACA,KAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,KAGU,OAAA,GAAU,cAAA,GAAiB,iBAAiB"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { QueryClientPlugin } from "@kontsedal/olas-core";
|
|
2
|
+
|
|
3
|
+
//#region src/channel.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Tiny `BroadcastChannel`-shaped abstraction. Lets tests inject a fake
|
|
6
|
+
* (a shared in-memory bus across multiple "tabs" in the same process) and
|
|
7
|
+
* keeps SSR-safety in one place — when `BroadcastChannel` is absent and
|
|
8
|
+
* no `channelFactory` override is supplied, the plugin returns a no-op
|
|
9
|
+
* variant up the stack.
|
|
10
|
+
*/
|
|
11
|
+
type ChannelLike = {
|
|
12
|
+
postMessage(data: unknown): void;
|
|
13
|
+
addEventListener(type: 'message', listener: (event: {
|
|
14
|
+
data: unknown;
|
|
15
|
+
}) => void): void;
|
|
16
|
+
removeEventListener(type: 'message', listener: (event: {
|
|
17
|
+
data: unknown;
|
|
18
|
+
}) => void): void;
|
|
19
|
+
close(): void;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Default factory: wraps the platform `BroadcastChannel`. Returns
|
|
23
|
+
* `undefined` when `BroadcastChannel` is not defined (SSR / Node without
|
|
24
|
+
* `--experimental-broadcastchannel`, older browsers).
|
|
25
|
+
*/
|
|
26
|
+
declare function defaultChannelFactory(name: string): ChannelLike | undefined;
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/plugin.d.ts
|
|
29
|
+
/**
|
|
30
|
+
* Options accepted by `crossTabPlugin(...)`. SPEC §13.2.
|
|
31
|
+
*
|
|
32
|
+
* - `channelName` — name of the `BroadcastChannel`. Required. Users who
|
|
33
|
+
* want clean cross-deploy isolation should include a version suffix
|
|
34
|
+
* (e.g. `'my-app/cache/v2'`).
|
|
35
|
+
* - `onWarn` — called for non-fatal conditions: a `DataCloneError` while
|
|
36
|
+
* posting (the data isn't structured-cloneable), or a malformed
|
|
37
|
+
* inbound message. Default: `console.warn`.
|
|
38
|
+
* - `channelFactory` — override the channel constructor. Mainly for
|
|
39
|
+
* tests that share an in-memory bus across two QueryClients.
|
|
40
|
+
*/
|
|
41
|
+
type CrossTabOptions = {
|
|
42
|
+
channelName: string;
|
|
43
|
+
onWarn?: (message: string, cause?: unknown) => void;
|
|
44
|
+
channelFactory?: (name: string) => ChannelLike | undefined;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /
|
|
48
|
+
* `invalidate` writes across tabs of the same origin.
|
|
49
|
+
*
|
|
50
|
+
* Wire it up via `RootOptions.plugins`:
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* createRoot(appController, {
|
|
54
|
+
* deps,
|
|
55
|
+
* plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],
|
|
56
|
+
* })
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`
|
|
60
|
+
* — the `queryId` routes inbound messages back to the right query, and
|
|
61
|
+
* `crossTab: true` is the per-query opt-in (queries that don't set it are
|
|
62
|
+
* ignored by the sender so module-internal queries don't leak).
|
|
63
|
+
*
|
|
64
|
+
* **SSR safety.** When `BroadcastChannel` is not defined (Node, older
|
|
65
|
+
* browsers without the feature) and no `channelFactory` override is
|
|
66
|
+
* supplied, the function returns a no-op plugin object. The root still
|
|
67
|
+
* boots cleanly; cross-tab is just disabled.
|
|
68
|
+
*
|
|
69
|
+
* **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache
|
|
70
|
+
* data containing functions, class instances, or symbols throws a
|
|
71
|
+
* `DataCloneError` at `postMessage`. The plugin catches the throw, calls
|
|
72
|
+
* `onWarn(...)`, and drops the message — the sender's cache is unaffected.
|
|
73
|
+
*/
|
|
74
|
+
declare function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin;
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/protocol.d.ts
|
|
77
|
+
/**
|
|
78
|
+
* Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.
|
|
79
|
+
*
|
|
80
|
+
* `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)
|
|
81
|
+
* combine to make the three-layer echo prevention work:
|
|
82
|
+
*
|
|
83
|
+
* 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the
|
|
84
|
+
* write originated from `applyRemoteSetData`).
|
|
85
|
+
* 2. Receiver filters its own `sourceId` (catches the case where the
|
|
86
|
+
* transport echoes the message back to the sender).
|
|
87
|
+
* 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order
|
|
88
|
+
* messages from the same peer are dropped.
|
|
89
|
+
*
|
|
90
|
+
* Receivers also drop messages with a `v` they don't understand. The
|
|
91
|
+
* channel name itself is user-supplied; consumers who want clean
|
|
92
|
+
* cross-deploy isolation should embed a version in their `channelName`.
|
|
93
|
+
*/
|
|
94
|
+
declare const PROTOCOL_VERSION = 1;
|
|
95
|
+
type SetDataMessage = {
|
|
96
|
+
v: typeof PROTOCOL_VERSION;
|
|
97
|
+
type: 'setData';
|
|
98
|
+
sourceId: string;
|
|
99
|
+
msgId: number;
|
|
100
|
+
queryId: string;
|
|
101
|
+
keyArgs: readonly unknown[];
|
|
102
|
+
data: unknown;
|
|
103
|
+
};
|
|
104
|
+
type InvalidateMessage = {
|
|
105
|
+
v: typeof PROTOCOL_VERSION;
|
|
106
|
+
type: 'invalidate';
|
|
107
|
+
sourceId: string;
|
|
108
|
+
msgId: number;
|
|
109
|
+
queryId: string;
|
|
110
|
+
keyArgs: readonly unknown[];
|
|
111
|
+
};
|
|
112
|
+
type Message = SetDataMessage | InvalidateMessage;
|
|
113
|
+
//#endregion
|
|
114
|
+
export { type ChannelLike, type CrossTabOptions, type InvalidateMessage, type Message, PROTOCOL_VERSION, type SetDataMessage, crossTabPlugin, defaultChannelFactory };
|
|
115
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/channel.ts","../src/plugin.ts","../src/protocol.ts"],"mappings":";;;;;;AAQA;;;;KAAY,WAAA;EACV,WAAA,CAAY,IAAA;EACZ,gBAAA,CAAiB,IAAA,aAAiB,QAAA,GAAW,KAAA;IAAS,IAAA;EAAA;EACtD,mBAAA,CAAoB,IAAA,aAAiB,QAAA,GAAW,KAAA;IAAS,IAAA;EAAA;EACzD,KAAA;AAAA;;;;;AAAK;iBAQS,qBAAA,CAAsB,IAAA,WAAe,WAAW;;;;AAZhE;;;;;;;;;;;KCeY,eAAA;EACV,WAAA;EACA,MAAA,IAAU,OAAA,UAAiB,KAAA;EAC3B,cAAA,IAAkB,IAAA,aAAiB,WAAW;AAAA;;;ADdzC;AAQP;;;;AAAgE;;;;ACGhE;;;;;;;;;;;;AAGgD;AA4ChD;;;;iBAAgB,cAAA,CAAe,OAAA,EAAS,eAAA,GAAkB,iBAAiB;;;;;;AD9D3E;;;;;;;;;;;;;;cEUa,gBAAA;AAAA,KAED,cAAA;EACV,CAAA,SAAU,gBAAgB;EAC1B,IAAA;EACA,QAAA;EACA,KAAA;EACA,OAAA;EACA,OAAA;EACA,IAAA;AAAA;AAAA,KAGU,iBAAA;EACV,CAAA,SAAU,gBAAgB;EAC1B,IAAA;EACA,QAAA;EACA,KAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,KAGU,OAAA,GAAU,cAAA,GAAiB,iBAAiB"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { lookupRegisteredQuery } from "@kontsedal/olas-core";
|
|
2
|
+
//#region src/channel.ts
|
|
3
|
+
/**
|
|
4
|
+
* Default factory: wraps the platform `BroadcastChannel`. Returns
|
|
5
|
+
* `undefined` when `BroadcastChannel` is not defined (SSR / Node without
|
|
6
|
+
* `--experimental-broadcastchannel`, older browsers).
|
|
7
|
+
*/
|
|
8
|
+
function defaultChannelFactory(name) {
|
|
9
|
+
if (typeof BroadcastChannel === "undefined") return void 0;
|
|
10
|
+
const ch = new BroadcastChannel(name);
|
|
11
|
+
return {
|
|
12
|
+
postMessage(data) {
|
|
13
|
+
ch.postMessage(data);
|
|
14
|
+
},
|
|
15
|
+
addEventListener(type, listener) {
|
|
16
|
+
ch.addEventListener(type, listener);
|
|
17
|
+
},
|
|
18
|
+
removeEventListener(type, listener) {
|
|
19
|
+
ch.removeEventListener(type, listener);
|
|
20
|
+
},
|
|
21
|
+
close() {
|
|
22
|
+
ch.close();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/protocol.ts
|
|
28
|
+
/**
|
|
29
|
+
* Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.
|
|
30
|
+
*
|
|
31
|
+
* `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)
|
|
32
|
+
* combine to make the three-layer echo prevention work:
|
|
33
|
+
*
|
|
34
|
+
* 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the
|
|
35
|
+
* write originated from `applyRemoteSetData`).
|
|
36
|
+
* 2. Receiver filters its own `sourceId` (catches the case where the
|
|
37
|
+
* transport echoes the message back to the sender).
|
|
38
|
+
* 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order
|
|
39
|
+
* messages from the same peer are dropped.
|
|
40
|
+
*
|
|
41
|
+
* Receivers also drop messages with a `v` they don't understand. The
|
|
42
|
+
* channel name itself is user-supplied; consumers who want clean
|
|
43
|
+
* cross-deploy isolation should embed a version in their `channelName`.
|
|
44
|
+
*/
|
|
45
|
+
const PROTOCOL_VERSION = 1;
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/plugin.ts
|
|
48
|
+
/**
|
|
49
|
+
* Generate a unique-enough source id for a plugin instance. Combines
|
|
50
|
+
* `Date.now()` with `Math.random()` — collisions across same-millisecond
|
|
51
|
+
* tab-opens are negligible at one-decimal-place randomness, and even a
|
|
52
|
+
* collision only loses dedup, not correctness.
|
|
53
|
+
*/
|
|
54
|
+
function makeSourceId() {
|
|
55
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
56
|
+
return `${Date.now().toString(36)}-${rand}`;
|
|
57
|
+
}
|
|
58
|
+
const NOOP_PLUGIN = {};
|
|
59
|
+
/**
|
|
60
|
+
* Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /
|
|
61
|
+
* `invalidate` writes across tabs of the same origin.
|
|
62
|
+
*
|
|
63
|
+
* Wire it up via `RootOptions.plugins`:
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* createRoot(appController, {
|
|
67
|
+
* deps,
|
|
68
|
+
* plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],
|
|
69
|
+
* })
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`
|
|
73
|
+
* — the `queryId` routes inbound messages back to the right query, and
|
|
74
|
+
* `crossTab: true` is the per-query opt-in (queries that don't set it are
|
|
75
|
+
* ignored by the sender so module-internal queries don't leak).
|
|
76
|
+
*
|
|
77
|
+
* **SSR safety.** When `BroadcastChannel` is not defined (Node, older
|
|
78
|
+
* browsers without the feature) and no `channelFactory` override is
|
|
79
|
+
* supplied, the function returns a no-op plugin object. The root still
|
|
80
|
+
* boots cleanly; cross-tab is just disabled.
|
|
81
|
+
*
|
|
82
|
+
* **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache
|
|
83
|
+
* data containing functions, class instances, or symbols throws a
|
|
84
|
+
* `DataCloneError` at `postMessage`. The plugin catches the throw, calls
|
|
85
|
+
* `onWarn(...)`, and drops the message — the sender's cache is unaffected.
|
|
86
|
+
*/
|
|
87
|
+
function crossTabPlugin(options) {
|
|
88
|
+
const channelName = options.channelName;
|
|
89
|
+
const onWarn = options.onWarn ?? defaultWarn;
|
|
90
|
+
const factory = options.channelFactory ?? defaultChannelFactory;
|
|
91
|
+
const probe = factory(channelName);
|
|
92
|
+
if (!probe) return NOOP_PLUGIN;
|
|
93
|
+
probe.close();
|
|
94
|
+
const sourceId = makeSourceId();
|
|
95
|
+
let msgIdCounter = 0;
|
|
96
|
+
const seenByPeer = /* @__PURE__ */ new Map();
|
|
97
|
+
let api = null;
|
|
98
|
+
let channel = null;
|
|
99
|
+
const listener = (event) => {
|
|
100
|
+
const msg = event.data;
|
|
101
|
+
if (!msg || typeof msg !== "object") return;
|
|
102
|
+
if (msg.v !== 1) return;
|
|
103
|
+
if (msg.sourceId === sourceId) return;
|
|
104
|
+
if (typeof msg.sourceId !== "string" || typeof msg.msgId !== "number") return;
|
|
105
|
+
const last = seenByPeer.get(msg.sourceId) ?? -1;
|
|
106
|
+
if (msg.msgId <= last) return;
|
|
107
|
+
seenByPeer.set(msg.sourceId, msg.msgId);
|
|
108
|
+
if (msg.type === "setData") {
|
|
109
|
+
if (typeof msg.queryId !== "string" || !Array.isArray(msg.keyArgs)) {
|
|
110
|
+
onWarn("[olas/cross-tab] malformed setData message");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
api?.applyRemoteSetData(msg.queryId, msg.keyArgs, msg.data);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (msg.type === "invalidate") {
|
|
117
|
+
if (typeof msg.queryId !== "string" || !Array.isArray(msg.keyArgs)) {
|
|
118
|
+
onWarn("[olas/cross-tab] malformed invalidate message");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
api?.applyRemoteInvalidate(msg.queryId, msg.keyArgs);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const send = (msg) => {
|
|
126
|
+
if (channel === null) return;
|
|
127
|
+
try {
|
|
128
|
+
channel.postMessage(msg);
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
onWarn(`[olas/cross-tab] failed to broadcast ${msg.type} for queryId="${msg.queryId}": data is not structured-cloneable`, cause);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
init(a) {
|
|
135
|
+
api = a;
|
|
136
|
+
channel = factory(channelName) ?? null;
|
|
137
|
+
channel?.addEventListener("message", listener);
|
|
138
|
+
},
|
|
139
|
+
onSetData(event) {
|
|
140
|
+
if (event.isRemote) return;
|
|
141
|
+
if (event.kind !== "data") return;
|
|
142
|
+
if (!shouldBroadcast(event.queryId)) return;
|
|
143
|
+
send({
|
|
144
|
+
v: 1,
|
|
145
|
+
type: "setData",
|
|
146
|
+
sourceId,
|
|
147
|
+
msgId: ++msgIdCounter,
|
|
148
|
+
queryId: event.queryId,
|
|
149
|
+
keyArgs: event.keyArgs,
|
|
150
|
+
data: event.data
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
onInvalidate(event) {
|
|
154
|
+
if (event.isRemote) return;
|
|
155
|
+
if (event.kind !== "data") return;
|
|
156
|
+
if (!shouldBroadcast(event.queryId)) return;
|
|
157
|
+
send({
|
|
158
|
+
v: 1,
|
|
159
|
+
type: "invalidate",
|
|
160
|
+
sourceId,
|
|
161
|
+
msgId: ++msgIdCounter,
|
|
162
|
+
queryId: event.queryId,
|
|
163
|
+
keyArgs: event.keyArgs
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
onGc(_event) {},
|
|
167
|
+
dispose() {
|
|
168
|
+
if (channel !== null) {
|
|
169
|
+
channel.removeEventListener("message", listener);
|
|
170
|
+
channel.close();
|
|
171
|
+
channel = null;
|
|
172
|
+
}
|
|
173
|
+
api = null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function defaultWarn(message, cause) {
|
|
178
|
+
if (cause !== void 0) console.warn(message, cause);
|
|
179
|
+
else console.warn(message);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Per-query gate. `crossTab: true` is a static opt-in on the spec; the
|
|
183
|
+
* QueryClient doesn't filter on it (its events fire for every query that
|
|
184
|
+
* has a `queryId`), so the plugin checks here. We look it up from the
|
|
185
|
+
* core's query registry on every event — no caching, since the registry
|
|
186
|
+
* lookup is a Map.get.
|
|
187
|
+
*/
|
|
188
|
+
function shouldBroadcast(queryId) {
|
|
189
|
+
const registered = lookupRegisteredQuery(queryId);
|
|
190
|
+
if (!registered) return false;
|
|
191
|
+
return registered.__spec.crossTab === true;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
export { PROTOCOL_VERSION, crossTabPlugin, defaultChannelFactory };
|
|
195
|
+
|
|
196
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/channel.ts","../src/protocol.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Tiny `BroadcastChannel`-shaped abstraction. Lets tests inject a fake\n * (a shared in-memory bus across multiple \"tabs\" in the same process) and\n * keeps SSR-safety in one place — when `BroadcastChannel` is absent and\n * no `channelFactory` override is supplied, the plugin returns a no-op\n * variant up the stack.\n */\n\nexport type ChannelLike = {\n postMessage(data: unknown): void\n addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n close(): void\n}\n\n/**\n * Default factory: wraps the platform `BroadcastChannel`. Returns\n * `undefined` when `BroadcastChannel` is not defined (SSR / Node without\n * `--experimental-broadcastchannel`, older browsers).\n */\nexport function defaultChannelFactory(name: string): ChannelLike | undefined {\n if (typeof BroadcastChannel === 'undefined') return undefined\n const ch = new BroadcastChannel(name)\n return {\n postMessage(data) {\n ch.postMessage(data)\n },\n addEventListener(type, listener) {\n // The platform BroadcastChannel typing wants a `MessageEvent`\n // listener, but we only care about `event.data` — cast through\n // `unknown` since the shapes don't structurally overlap.\n ch.addEventListener(type, listener as unknown as EventListener)\n },\n removeEventListener(type, listener) {\n ch.removeEventListener(type, listener as unknown as EventListener)\n },\n close() {\n ch.close()\n },\n }\n}\n","/**\n * Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.\n *\n * `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)\n * combine to make the three-layer echo prevention work:\n *\n * 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the\n * write originated from `applyRemoteSetData`).\n * 2. Receiver filters its own `sourceId` (catches the case where the\n * transport echoes the message back to the sender).\n * 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order\n * messages from the same peer are dropped.\n *\n * Receivers also drop messages with a `v` they don't understand. The\n * channel name itself is user-supplied; consumers who want clean\n * cross-deploy isolation should embed a version in their `channelName`.\n */\n\nexport const PROTOCOL_VERSION = 1\n\nexport type SetDataMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'setData'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n data: unknown\n}\n\nexport type InvalidateMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'invalidate'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n}\n\nexport type Message = SetDataMessage | InvalidateMessage\n","import {\n type GcEvent,\n type InvalidateEvent,\n lookupRegisteredQuery,\n type QueryClientPlugin,\n type QueryClientPluginApi,\n type SetDataEvent,\n} from '@kontsedal/olas-core'\nimport { type ChannelLike, defaultChannelFactory } from './channel'\nimport { type Message, PROTOCOL_VERSION } from './protocol'\n\n/**\n * Options accepted by `crossTabPlugin(...)`. SPEC §13.2.\n *\n * - `channelName` — name of the `BroadcastChannel`. Required. Users who\n * want clean cross-deploy isolation should include a version suffix\n * (e.g. `'my-app/cache/v2'`).\n * - `onWarn` — called for non-fatal conditions: a `DataCloneError` while\n * posting (the data isn't structured-cloneable), or a malformed\n * inbound message. Default: `console.warn`.\n * - `channelFactory` — override the channel constructor. Mainly for\n * tests that share an in-memory bus across two QueryClients.\n */\nexport type CrossTabOptions = {\n channelName: string\n onWarn?: (message: string, cause?: unknown) => void\n channelFactory?: (name: string) => ChannelLike | undefined\n}\n\n/**\n * Generate a unique-enough source id for a plugin instance. Combines\n * `Date.now()` with `Math.random()` — collisions across same-millisecond\n * tab-opens are negligible at one-decimal-place randomness, and even a\n * collision only loses dedup, not correctness.\n */\nfunction makeSourceId(): string {\n const rand = Math.random().toString(36).slice(2, 10)\n return `${Date.now().toString(36)}-${rand}`\n}\n\nconst NOOP_PLUGIN: QueryClientPlugin = {}\n\n/**\n * Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /\n * `invalidate` writes across tabs of the same origin.\n *\n * Wire it up via `RootOptions.plugins`:\n *\n * ```ts\n * createRoot(appController, {\n * deps,\n * plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],\n * })\n * ```\n *\n * Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`\n * — the `queryId` routes inbound messages back to the right query, and\n * `crossTab: true` is the per-query opt-in (queries that don't set it are\n * ignored by the sender so module-internal queries don't leak).\n *\n * **SSR safety.** When `BroadcastChannel` is not defined (Node, older\n * browsers without the feature) and no `channelFactory` override is\n * supplied, the function returns a no-op plugin object. The root still\n * boots cleanly; cross-tab is just disabled.\n *\n * **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache\n * data containing functions, class instances, or symbols throws a\n * `DataCloneError` at `postMessage`. The plugin catches the throw, calls\n * `onWarn(...)`, and drops the message — the sender's cache is unaffected.\n */\nexport function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {\n const channelName = options.channelName\n const onWarn = options.onWarn ?? defaultWarn\n const factory = options.channelFactory ?? defaultChannelFactory\n\n // Cheap probe — if the environment can't produce a channel at all we can\n // return a no-op plugin without opening anything. The real channel opens\n // lazily in `init` so a plugin that's constructed but never passed to a\n // root doesn't leak a BroadcastChannel.\n const probe = factory(channelName)\n if (!probe) {\n // SSR / unsupported environment. Caller's plugin slot still receives a\n // valid plugin object; it just does nothing.\n return NOOP_PLUGIN\n }\n probe.close()\n\n const sourceId = makeSourceId()\n let msgIdCounter = 0\n const seenByPeer = new Map<string, number>()\n let api: QueryClientPluginApi | null = null\n let channel: ChannelLike | null = null\n\n const listener = (event: { data: unknown }) => {\n const msg = event.data as Partial<Message> | null\n if (!msg || typeof msg !== 'object') return\n // Layer 1 — protocol version drop.\n if (msg.v !== PROTOCOL_VERSION) return\n // Layer 2 — own-source drop (transport echoed our own message back).\n if (msg.sourceId === sourceId) return\n // Layer 3 — out-of-order / duplicate drop.\n if (typeof msg.sourceId !== 'string' || typeof msg.msgId !== 'number') return\n const last = seenByPeer.get(msg.sourceId) ?? -1\n if (msg.msgId <= last) return\n seenByPeer.set(msg.sourceId, msg.msgId)\n\n if (msg.type === 'setData') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed setData message')\n return\n }\n api?.applyRemoteSetData(msg.queryId, msg.keyArgs, msg.data)\n return\n }\n if (msg.type === 'invalidate') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed invalidate message')\n return\n }\n api?.applyRemoteInvalidate(msg.queryId, msg.keyArgs)\n return\n }\n }\n\n const send = (msg: Message): void => {\n if (channel === null) return\n try {\n channel.postMessage(msg)\n } catch (cause) {\n // Structured clone failed — most likely non-cloneable data on a\n // setData payload. Warn and drop.\n onWarn(\n `[olas/cross-tab] failed to broadcast ${msg.type} for queryId=\"${msg.queryId}\": data is not structured-cloneable`,\n cause,\n )\n }\n }\n\n return {\n init(a) {\n api = a\n channel = factory(channelName) ?? null\n channel?.addEventListener('message', listener)\n },\n\n onSetData(event: SetDataEvent) {\n // Don't echo inbound writes — Layer-1 sender-side echo prevention.\n if (event.isRemote) return\n // Infinite queries are deferred for v1 — see SPEC §13.2.\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'setData',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n data: event.data,\n })\n },\n\n onInvalidate(event: InvalidateEvent) {\n if (event.isRemote) return\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'invalidate',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n })\n },\n\n onGc(_event: GcEvent) {\n // GC is local — we don't propagate it. Each tab gc's its own entries\n // when its own subscribers drop.\n },\n\n dispose() {\n if (channel !== null) {\n channel.removeEventListener('message', listener)\n channel.close()\n channel = null\n }\n api = null\n },\n }\n}\n\nfunction defaultWarn(message: string, cause?: unknown): void {\n if (cause !== undefined) {\n console.warn(message, cause)\n } else {\n console.warn(message)\n }\n}\n\n/**\n * Per-query gate. `crossTab: true` is a static opt-in on the spec; the\n * QueryClient doesn't filter on it (its events fire for every query that\n * has a `queryId`), so the plugin checks here. We look it up from the\n * core's query registry on every event — no caching, since the registry\n * lookup is a Map.get.\n */\nfunction shouldBroadcast(queryId: string): boolean {\n const registered = lookupRegisteredQuery(queryId)\n if (!registered) return false\n return registered.__spec.crossTab === true\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kontsedal/olas-cross-tab",
|
|
3
|
+
"version": "0.0.1-rc.0",
|
|
4
|
+
"description": "Olas cross-tab in-memory query cache sync via BroadcastChannel",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.mjs",
|
|
9
|
+
"types": "./dist/index.d.cts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/index.d.mts",
|
|
14
|
+
"default": "./dist/index.mjs"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/index.d.cts",
|
|
18
|
+
"default": "./dist/index.cjs"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@kontsedal/olas-core": "^0.0.1-rc.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@kontsedal/olas-core": "^0.0.1-rc.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsdown",
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny `BroadcastChannel`-shaped abstraction. Lets tests inject a fake
|
|
3
|
+
* (a shared in-memory bus across multiple "tabs" in the same process) and
|
|
4
|
+
* keeps SSR-safety in one place — when `BroadcastChannel` is absent and
|
|
5
|
+
* no `channelFactory` override is supplied, the plugin returns a no-op
|
|
6
|
+
* variant up the stack.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type ChannelLike = {
|
|
10
|
+
postMessage(data: unknown): void
|
|
11
|
+
addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void
|
|
12
|
+
removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void
|
|
13
|
+
close(): void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default factory: wraps the platform `BroadcastChannel`. Returns
|
|
18
|
+
* `undefined` when `BroadcastChannel` is not defined (SSR / Node without
|
|
19
|
+
* `--experimental-broadcastchannel`, older browsers).
|
|
20
|
+
*/
|
|
21
|
+
export function defaultChannelFactory(name: string): ChannelLike | undefined {
|
|
22
|
+
if (typeof BroadcastChannel === 'undefined') return undefined
|
|
23
|
+
const ch = new BroadcastChannel(name)
|
|
24
|
+
return {
|
|
25
|
+
postMessage(data) {
|
|
26
|
+
ch.postMessage(data)
|
|
27
|
+
},
|
|
28
|
+
addEventListener(type, listener) {
|
|
29
|
+
// The platform BroadcastChannel typing wants a `MessageEvent`
|
|
30
|
+
// listener, but we only care about `event.data` — cast through
|
|
31
|
+
// `unknown` since the shapes don't structurally overlap.
|
|
32
|
+
ch.addEventListener(type, listener as unknown as EventListener)
|
|
33
|
+
},
|
|
34
|
+
removeEventListener(type, listener) {
|
|
35
|
+
ch.removeEventListener(type, listener as unknown as EventListener)
|
|
36
|
+
},
|
|
37
|
+
close() {
|
|
38
|
+
ch.close()
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@kontsedal/olas-cross-tab` — BroadcastChannel-backed in-memory cache sync across
|
|
3
|
+
* tabs of the same origin. See SPEC §13.2 and the package README.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type { ChannelLike } from './channel'
|
|
7
|
+
export { defaultChannelFactory } from './channel'
|
|
8
|
+
export { type CrossTabOptions, crossTabPlugin } from './plugin'
|
|
9
|
+
export {
|
|
10
|
+
type InvalidateMessage,
|
|
11
|
+
type Message,
|
|
12
|
+
PROTOCOL_VERSION,
|
|
13
|
+
type SetDataMessage,
|
|
14
|
+
} from './protocol'
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type GcEvent,
|
|
3
|
+
type InvalidateEvent,
|
|
4
|
+
lookupRegisteredQuery,
|
|
5
|
+
type QueryClientPlugin,
|
|
6
|
+
type QueryClientPluginApi,
|
|
7
|
+
type SetDataEvent,
|
|
8
|
+
} from '@kontsedal/olas-core'
|
|
9
|
+
import { type ChannelLike, defaultChannelFactory } from './channel'
|
|
10
|
+
import { type Message, PROTOCOL_VERSION } from './protocol'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options accepted by `crossTabPlugin(...)`. SPEC §13.2.
|
|
14
|
+
*
|
|
15
|
+
* - `channelName` — name of the `BroadcastChannel`. Required. Users who
|
|
16
|
+
* want clean cross-deploy isolation should include a version suffix
|
|
17
|
+
* (e.g. `'my-app/cache/v2'`).
|
|
18
|
+
* - `onWarn` — called for non-fatal conditions: a `DataCloneError` while
|
|
19
|
+
* posting (the data isn't structured-cloneable), or a malformed
|
|
20
|
+
* inbound message. Default: `console.warn`.
|
|
21
|
+
* - `channelFactory` — override the channel constructor. Mainly for
|
|
22
|
+
* tests that share an in-memory bus across two QueryClients.
|
|
23
|
+
*/
|
|
24
|
+
export type CrossTabOptions = {
|
|
25
|
+
channelName: string
|
|
26
|
+
onWarn?: (message: string, cause?: unknown) => void
|
|
27
|
+
channelFactory?: (name: string) => ChannelLike | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate a unique-enough source id for a plugin instance. Combines
|
|
32
|
+
* `Date.now()` with `Math.random()` — collisions across same-millisecond
|
|
33
|
+
* tab-opens are negligible at one-decimal-place randomness, and even a
|
|
34
|
+
* collision only loses dedup, not correctness.
|
|
35
|
+
*/
|
|
36
|
+
function makeSourceId(): string {
|
|
37
|
+
const rand = Math.random().toString(36).slice(2, 10)
|
|
38
|
+
return `${Date.now().toString(36)}-${rand}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const NOOP_PLUGIN: QueryClientPlugin = {}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /
|
|
45
|
+
* `invalidate` writes across tabs of the same origin.
|
|
46
|
+
*
|
|
47
|
+
* Wire it up via `RootOptions.plugins`:
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* createRoot(appController, {
|
|
51
|
+
* deps,
|
|
52
|
+
* plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],
|
|
53
|
+
* })
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`
|
|
57
|
+
* — the `queryId` routes inbound messages back to the right query, and
|
|
58
|
+
* `crossTab: true` is the per-query opt-in (queries that don't set it are
|
|
59
|
+
* ignored by the sender so module-internal queries don't leak).
|
|
60
|
+
*
|
|
61
|
+
* **SSR safety.** When `BroadcastChannel` is not defined (Node, older
|
|
62
|
+
* browsers without the feature) and no `channelFactory` override is
|
|
63
|
+
* supplied, the function returns a no-op plugin object. The root still
|
|
64
|
+
* boots cleanly; cross-tab is just disabled.
|
|
65
|
+
*
|
|
66
|
+
* **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache
|
|
67
|
+
* data containing functions, class instances, or symbols throws a
|
|
68
|
+
* `DataCloneError` at `postMessage`. The plugin catches the throw, calls
|
|
69
|
+
* `onWarn(...)`, and drops the message — the sender's cache is unaffected.
|
|
70
|
+
*/
|
|
71
|
+
export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
|
|
72
|
+
const channelName = options.channelName
|
|
73
|
+
const onWarn = options.onWarn ?? defaultWarn
|
|
74
|
+
const factory = options.channelFactory ?? defaultChannelFactory
|
|
75
|
+
|
|
76
|
+
// Cheap probe — if the environment can't produce a channel at all we can
|
|
77
|
+
// return a no-op plugin without opening anything. The real channel opens
|
|
78
|
+
// lazily in `init` so a plugin that's constructed but never passed to a
|
|
79
|
+
// root doesn't leak a BroadcastChannel.
|
|
80
|
+
const probe = factory(channelName)
|
|
81
|
+
if (!probe) {
|
|
82
|
+
// SSR / unsupported environment. Caller's plugin slot still receives a
|
|
83
|
+
// valid plugin object; it just does nothing.
|
|
84
|
+
return NOOP_PLUGIN
|
|
85
|
+
}
|
|
86
|
+
probe.close()
|
|
87
|
+
|
|
88
|
+
const sourceId = makeSourceId()
|
|
89
|
+
let msgIdCounter = 0
|
|
90
|
+
const seenByPeer = new Map<string, number>()
|
|
91
|
+
let api: QueryClientPluginApi | null = null
|
|
92
|
+
let channel: ChannelLike | null = null
|
|
93
|
+
|
|
94
|
+
const listener = (event: { data: unknown }) => {
|
|
95
|
+
const msg = event.data as Partial<Message> | null
|
|
96
|
+
if (!msg || typeof msg !== 'object') return
|
|
97
|
+
// Layer 1 — protocol version drop.
|
|
98
|
+
if (msg.v !== PROTOCOL_VERSION) return
|
|
99
|
+
// Layer 2 — own-source drop (transport echoed our own message back).
|
|
100
|
+
if (msg.sourceId === sourceId) return
|
|
101
|
+
// Layer 3 — out-of-order / duplicate drop.
|
|
102
|
+
if (typeof msg.sourceId !== 'string' || typeof msg.msgId !== 'number') return
|
|
103
|
+
const last = seenByPeer.get(msg.sourceId) ?? -1
|
|
104
|
+
if (msg.msgId <= last) return
|
|
105
|
+
seenByPeer.set(msg.sourceId, msg.msgId)
|
|
106
|
+
|
|
107
|
+
if (msg.type === 'setData') {
|
|
108
|
+
if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {
|
|
109
|
+
onWarn('[olas/cross-tab] malformed setData message')
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
api?.applyRemoteSetData(msg.queryId, msg.keyArgs, msg.data)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (msg.type === 'invalidate') {
|
|
116
|
+
if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {
|
|
117
|
+
onWarn('[olas/cross-tab] malformed invalidate message')
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
api?.applyRemoteInvalidate(msg.queryId, msg.keyArgs)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const send = (msg: Message): void => {
|
|
126
|
+
if (channel === null) return
|
|
127
|
+
try {
|
|
128
|
+
channel.postMessage(msg)
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
// Structured clone failed — most likely non-cloneable data on a
|
|
131
|
+
// setData payload. Warn and drop.
|
|
132
|
+
onWarn(
|
|
133
|
+
`[olas/cross-tab] failed to broadcast ${msg.type} for queryId="${msg.queryId}": data is not structured-cloneable`,
|
|
134
|
+
cause,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
init(a) {
|
|
141
|
+
api = a
|
|
142
|
+
channel = factory(channelName) ?? null
|
|
143
|
+
channel?.addEventListener('message', listener)
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
onSetData(event: SetDataEvent) {
|
|
147
|
+
// Don't echo inbound writes — Layer-1 sender-side echo prevention.
|
|
148
|
+
if (event.isRemote) return
|
|
149
|
+
// Infinite queries are deferred for v1 — see SPEC §13.2.
|
|
150
|
+
if (event.kind !== 'data') return
|
|
151
|
+
if (!shouldBroadcast(event.queryId)) return
|
|
152
|
+
|
|
153
|
+
send({
|
|
154
|
+
v: PROTOCOL_VERSION,
|
|
155
|
+
type: 'setData',
|
|
156
|
+
sourceId,
|
|
157
|
+
msgId: ++msgIdCounter,
|
|
158
|
+
queryId: event.queryId,
|
|
159
|
+
keyArgs: event.keyArgs,
|
|
160
|
+
data: event.data,
|
|
161
|
+
})
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
onInvalidate(event: InvalidateEvent) {
|
|
165
|
+
if (event.isRemote) return
|
|
166
|
+
if (event.kind !== 'data') return
|
|
167
|
+
if (!shouldBroadcast(event.queryId)) return
|
|
168
|
+
|
|
169
|
+
send({
|
|
170
|
+
v: PROTOCOL_VERSION,
|
|
171
|
+
type: 'invalidate',
|
|
172
|
+
sourceId,
|
|
173
|
+
msgId: ++msgIdCounter,
|
|
174
|
+
queryId: event.queryId,
|
|
175
|
+
keyArgs: event.keyArgs,
|
|
176
|
+
})
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
onGc(_event: GcEvent) {
|
|
180
|
+
// GC is local — we don't propagate it. Each tab gc's its own entries
|
|
181
|
+
// when its own subscribers drop.
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
dispose() {
|
|
185
|
+
if (channel !== null) {
|
|
186
|
+
channel.removeEventListener('message', listener)
|
|
187
|
+
channel.close()
|
|
188
|
+
channel = null
|
|
189
|
+
}
|
|
190
|
+
api = null
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function defaultWarn(message: string, cause?: unknown): void {
|
|
196
|
+
if (cause !== undefined) {
|
|
197
|
+
console.warn(message, cause)
|
|
198
|
+
} else {
|
|
199
|
+
console.warn(message)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Per-query gate. `crossTab: true` is a static opt-in on the spec; the
|
|
205
|
+
* QueryClient doesn't filter on it (its events fire for every query that
|
|
206
|
+
* has a `queryId`), so the plugin checks here. We look it up from the
|
|
207
|
+
* core's query registry on every event — no caching, since the registry
|
|
208
|
+
* lookup is a Map.get.
|
|
209
|
+
*/
|
|
210
|
+
function shouldBroadcast(queryId: string): boolean {
|
|
211
|
+
const registered = lookupRegisteredQuery(queryId)
|
|
212
|
+
if (!registered) return false
|
|
213
|
+
return registered.__spec.crossTab === true
|
|
214
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.
|
|
3
|
+
*
|
|
4
|
+
* `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)
|
|
5
|
+
* combine to make the three-layer echo prevention work:
|
|
6
|
+
*
|
|
7
|
+
* 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the
|
|
8
|
+
* write originated from `applyRemoteSetData`).
|
|
9
|
+
* 2. Receiver filters its own `sourceId` (catches the case where the
|
|
10
|
+
* transport echoes the message back to the sender).
|
|
11
|
+
* 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order
|
|
12
|
+
* messages from the same peer are dropped.
|
|
13
|
+
*
|
|
14
|
+
* Receivers also drop messages with a `v` they don't understand. The
|
|
15
|
+
* channel name itself is user-supplied; consumers who want clean
|
|
16
|
+
* cross-deploy isolation should embed a version in their `channelName`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const PROTOCOL_VERSION = 1
|
|
20
|
+
|
|
21
|
+
export type SetDataMessage = {
|
|
22
|
+
v: typeof PROTOCOL_VERSION
|
|
23
|
+
type: 'setData'
|
|
24
|
+
sourceId: string
|
|
25
|
+
msgId: number
|
|
26
|
+
queryId: string
|
|
27
|
+
keyArgs: readonly unknown[]
|
|
28
|
+
data: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type InvalidateMessage = {
|
|
32
|
+
v: typeof PROTOCOL_VERSION
|
|
33
|
+
type: 'invalidate'
|
|
34
|
+
sourceId: string
|
|
35
|
+
msgId: number
|
|
36
|
+
queryId: string
|
|
37
|
+
keyArgs: readonly unknown[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Message = SetDataMessage | InvalidateMessage
|