@kontsedal/olas-cross-tab 0.0.1-rc.1 → 0.0.2
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/dist/index.cjs +16 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +16 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/plugin.ts +43 -1
package/dist/index.cjs
CHANGED
|
@@ -94,9 +94,18 @@ function crossTabPlugin(options) {
|
|
|
94
94
|
probe.close();
|
|
95
95
|
const sourceId = makeSourceId();
|
|
96
96
|
let msgIdCounter = 0;
|
|
97
|
+
const MAX_PEERS = 64;
|
|
97
98
|
const seenByPeer = /* @__PURE__ */ new Map();
|
|
98
99
|
let api = null;
|
|
99
100
|
let channel = null;
|
|
101
|
+
const recordPeerMsg = (peerId, msgId) => {
|
|
102
|
+
if (seenByPeer.has(peerId)) seenByPeer.delete(peerId);
|
|
103
|
+
seenByPeer.set(peerId, msgId);
|
|
104
|
+
if (seenByPeer.size > MAX_PEERS) {
|
|
105
|
+
const oldest = seenByPeer.keys().next().value;
|
|
106
|
+
if (oldest !== void 0) seenByPeer.delete(oldest);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
100
109
|
const listener = (event) => {
|
|
101
110
|
const msg = event.data;
|
|
102
111
|
if (!msg || typeof msg !== "object") return;
|
|
@@ -105,7 +114,7 @@ function crossTabPlugin(options) {
|
|
|
105
114
|
if (typeof msg.sourceId !== "string" || typeof msg.msgId !== "number") return;
|
|
106
115
|
const last = seenByPeer.get(msg.sourceId) ?? -1;
|
|
107
116
|
if (msg.msgId <= last) return;
|
|
108
|
-
|
|
117
|
+
recordPeerMsg(msg.sourceId, msg.msgId);
|
|
109
118
|
if (msg.type === "setData") {
|
|
110
119
|
if (typeof msg.queryId !== "string" || !Array.isArray(msg.keyArgs)) {
|
|
111
120
|
onWarn("[olas/cross-tab] malformed setData message");
|
|
@@ -131,14 +140,18 @@ function crossTabPlugin(options) {
|
|
|
131
140
|
onWarn(`[olas/cross-tab] failed to broadcast ${msg.type} for queryId="${msg.queryId}": data is not structured-cloneable`, cause);
|
|
132
141
|
}
|
|
133
142
|
};
|
|
143
|
+
let initialized = false;
|
|
134
144
|
return {
|
|
135
145
|
init(a) {
|
|
146
|
+
if (initialized) throw new Error("[olas/cross-tab] crossTabPlugin instance reused across multiple roots. Each root must get its own `crossTabPlugin({ ... })`.");
|
|
147
|
+
initialized = true;
|
|
136
148
|
api = a;
|
|
137
149
|
channel = factory(channelName) ?? null;
|
|
138
150
|
channel?.addEventListener("message", listener);
|
|
139
151
|
},
|
|
140
152
|
onSetData(event) {
|
|
141
153
|
if (event.isRemote) return;
|
|
154
|
+
if (event.source === "fetch") return;
|
|
142
155
|
if (event.kind !== "data") return;
|
|
143
156
|
if (!shouldBroadcast(event.queryId)) return;
|
|
144
157
|
send({
|
|
@@ -172,6 +185,8 @@ function crossTabPlugin(options) {
|
|
|
172
185
|
channel = null;
|
|
173
186
|
}
|
|
174
187
|
api = null;
|
|
188
|
+
seenByPeer.clear();
|
|
189
|
+
initialized = false;
|
|
175
190
|
}
|
|
176
191
|
};
|
|
177
192
|
}
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +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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"}
|
|
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 // Per-peer monotonic-id cursor for out-of-order / duplicate drops. We cap\n // the number of distinct peers we remember so a long-lived root that sees\n // many short-lived peers (tabs that open, write once, and close) doesn't\n // grow this Map without bound. When the cap is hit we evict in insertion-\n // order (LRU-ish — peers we haven't heard from in the longest time go\n // first). A peer we later hear from again will simply start with `last=-1`\n // and accept its next message; the only cost of eviction is a one-message\n // dedup miss in the rare case the transport actually echoes that exact\n // peer's last-seen message back to us, which `BroadcastChannel` does not.\n const MAX_PEERS = 64\n const seenByPeer = new Map<string, number>()\n let api: QueryClientPluginApi | null = null\n let channel: ChannelLike | null = null\n\n const recordPeerMsg = (peerId: string, msgId: number): void => {\n // LRU touch — delete + set re-inserts at the tail so the oldest peers\n // sit at the head of the iterator.\n if (seenByPeer.has(peerId)) seenByPeer.delete(peerId)\n seenByPeer.set(peerId, msgId)\n if (seenByPeer.size > MAX_PEERS) {\n const oldest = seenByPeer.keys().next().value\n if (oldest !== undefined) seenByPeer.delete(oldest)\n }\n }\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 recordPeerMsg(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 let initialized = false\n return {\n init(a) {\n // The plugin instance owns one `sourceId`, one channel, and one\n // listener Map. Sharing it across two roots would clobber that state\n // on the second `init` and leak the first channel. Construct a fresh\n // `crossTabPlugin({ ... })` per root.\n if (initialized) {\n throw new Error(\n '[olas/cross-tab] crossTabPlugin instance reused across multiple roots. ' +\n 'Each root must get its own `crossTabPlugin({ ... })`.',\n )\n }\n initialized = true\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 // Fetch-success writes are a per-tab concern: every tab runs its own\n // fetcher and would otherwise rebroadcast results to peers that just\n // fetched the same data themselves. We only echo explicit `setData`\n // calls (mutations, optimistic patches, entity backprop) cross-tab.\n if (event.source === 'fetch') 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 seenByPeer.clear()\n // Allow re-`init` only if a future runtime explicitly reattaches; today\n // `QueryClient.dispose` is final, so this is mostly defensive.\n initialized = false\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.mjs
CHANGED
|
@@ -93,9 +93,18 @@ function crossTabPlugin(options) {
|
|
|
93
93
|
probe.close();
|
|
94
94
|
const sourceId = makeSourceId();
|
|
95
95
|
let msgIdCounter = 0;
|
|
96
|
+
const MAX_PEERS = 64;
|
|
96
97
|
const seenByPeer = /* @__PURE__ */ new Map();
|
|
97
98
|
let api = null;
|
|
98
99
|
let channel = null;
|
|
100
|
+
const recordPeerMsg = (peerId, msgId) => {
|
|
101
|
+
if (seenByPeer.has(peerId)) seenByPeer.delete(peerId);
|
|
102
|
+
seenByPeer.set(peerId, msgId);
|
|
103
|
+
if (seenByPeer.size > MAX_PEERS) {
|
|
104
|
+
const oldest = seenByPeer.keys().next().value;
|
|
105
|
+
if (oldest !== void 0) seenByPeer.delete(oldest);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
99
108
|
const listener = (event) => {
|
|
100
109
|
const msg = event.data;
|
|
101
110
|
if (!msg || typeof msg !== "object") return;
|
|
@@ -104,7 +113,7 @@ function crossTabPlugin(options) {
|
|
|
104
113
|
if (typeof msg.sourceId !== "string" || typeof msg.msgId !== "number") return;
|
|
105
114
|
const last = seenByPeer.get(msg.sourceId) ?? -1;
|
|
106
115
|
if (msg.msgId <= last) return;
|
|
107
|
-
|
|
116
|
+
recordPeerMsg(msg.sourceId, msg.msgId);
|
|
108
117
|
if (msg.type === "setData") {
|
|
109
118
|
if (typeof msg.queryId !== "string" || !Array.isArray(msg.keyArgs)) {
|
|
110
119
|
onWarn("[olas/cross-tab] malformed setData message");
|
|
@@ -130,14 +139,18 @@ function crossTabPlugin(options) {
|
|
|
130
139
|
onWarn(`[olas/cross-tab] failed to broadcast ${msg.type} for queryId="${msg.queryId}": data is not structured-cloneable`, cause);
|
|
131
140
|
}
|
|
132
141
|
};
|
|
142
|
+
let initialized = false;
|
|
133
143
|
return {
|
|
134
144
|
init(a) {
|
|
145
|
+
if (initialized) throw new Error("[olas/cross-tab] crossTabPlugin instance reused across multiple roots. Each root must get its own `crossTabPlugin({ ... })`.");
|
|
146
|
+
initialized = true;
|
|
135
147
|
api = a;
|
|
136
148
|
channel = factory(channelName) ?? null;
|
|
137
149
|
channel?.addEventListener("message", listener);
|
|
138
150
|
},
|
|
139
151
|
onSetData(event) {
|
|
140
152
|
if (event.isRemote) return;
|
|
153
|
+
if (event.source === "fetch") return;
|
|
141
154
|
if (event.kind !== "data") return;
|
|
142
155
|
if (!shouldBroadcast(event.queryId)) return;
|
|
143
156
|
send({
|
|
@@ -171,6 +184,8 @@ function crossTabPlugin(options) {
|
|
|
171
184
|
channel = null;
|
|
172
185
|
}
|
|
173
186
|
api = null;
|
|
187
|
+
seenByPeer.clear();
|
|
188
|
+
initialized = false;
|
|
174
189
|
}
|
|
175
190
|
};
|
|
176
191
|
}
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +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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"}
|
|
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 // Per-peer monotonic-id cursor for out-of-order / duplicate drops. We cap\n // the number of distinct peers we remember so a long-lived root that sees\n // many short-lived peers (tabs that open, write once, and close) doesn't\n // grow this Map without bound. When the cap is hit we evict in insertion-\n // order (LRU-ish — peers we haven't heard from in the longest time go\n // first). A peer we later hear from again will simply start with `last=-1`\n // and accept its next message; the only cost of eviction is a one-message\n // dedup miss in the rare case the transport actually echoes that exact\n // peer's last-seen message back to us, which `BroadcastChannel` does not.\n const MAX_PEERS = 64\n const seenByPeer = new Map<string, number>()\n let api: QueryClientPluginApi | null = null\n let channel: ChannelLike | null = null\n\n const recordPeerMsg = (peerId: string, msgId: number): void => {\n // LRU touch — delete + set re-inserts at the tail so the oldest peers\n // sit at the head of the iterator.\n if (seenByPeer.has(peerId)) seenByPeer.delete(peerId)\n seenByPeer.set(peerId, msgId)\n if (seenByPeer.size > MAX_PEERS) {\n const oldest = seenByPeer.keys().next().value\n if (oldest !== undefined) seenByPeer.delete(oldest)\n }\n }\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 recordPeerMsg(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 let initialized = false\n return {\n init(a) {\n // The plugin instance owns one `sourceId`, one channel, and one\n // listener Map. Sharing it across two roots would clobber that state\n // on the second `init` and leak the first channel. Construct a fresh\n // `crossTabPlugin({ ... })` per root.\n if (initialized) {\n throw new Error(\n '[olas/cross-tab] crossTabPlugin instance reused across multiple roots. ' +\n 'Each root must get its own `crossTabPlugin({ ... })`.',\n )\n }\n initialized = true\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 // Fetch-success writes are a per-tab concern: every tab runs its own\n // fetcher and would otherwise rebroadcast results to peers that just\n // fetched the same data themselves. We only echo explicit `setData`\n // calls (mutations, optimistic patches, entity backprop) cross-tab.\n if (event.source === 'fetch') 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 seenByPeer.clear()\n // Allow re-`init` only if a future runtime explicitly reattaches; today\n // `QueryClient.dispose` is final, so this is mostly defensive.\n initialized = false\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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontsedal/olas-cross-tab",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Olas cross-tab cache sync — BroadcastChannel-backed QueryClientPlugin keeping every browser tab in lockstep.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"olas",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
],
|
|
46
46
|
"sideEffects": false,
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@kontsedal/olas-core": "^0.0.
|
|
48
|
+
"@kontsedal/olas-core": "^0.0.2"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@kontsedal/olas-core": "^0.0.
|
|
51
|
+
"@kontsedal/olas-core": "^0.0.2"
|
|
52
52
|
},
|
|
53
53
|
"publishConfig": {
|
|
54
54
|
"access": "public"
|
package/src/plugin.ts
CHANGED
|
@@ -87,10 +87,31 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
|
|
|
87
87
|
|
|
88
88
|
const sourceId = makeSourceId()
|
|
89
89
|
let msgIdCounter = 0
|
|
90
|
+
// Per-peer monotonic-id cursor for out-of-order / duplicate drops. We cap
|
|
91
|
+
// the number of distinct peers we remember so a long-lived root that sees
|
|
92
|
+
// many short-lived peers (tabs that open, write once, and close) doesn't
|
|
93
|
+
// grow this Map without bound. When the cap is hit we evict in insertion-
|
|
94
|
+
// order (LRU-ish — peers we haven't heard from in the longest time go
|
|
95
|
+
// first). A peer we later hear from again will simply start with `last=-1`
|
|
96
|
+
// and accept its next message; the only cost of eviction is a one-message
|
|
97
|
+
// dedup miss in the rare case the transport actually echoes that exact
|
|
98
|
+
// peer's last-seen message back to us, which `BroadcastChannel` does not.
|
|
99
|
+
const MAX_PEERS = 64
|
|
90
100
|
const seenByPeer = new Map<string, number>()
|
|
91
101
|
let api: QueryClientPluginApi | null = null
|
|
92
102
|
let channel: ChannelLike | null = null
|
|
93
103
|
|
|
104
|
+
const recordPeerMsg = (peerId: string, msgId: number): void => {
|
|
105
|
+
// LRU touch — delete + set re-inserts at the tail so the oldest peers
|
|
106
|
+
// sit at the head of the iterator.
|
|
107
|
+
if (seenByPeer.has(peerId)) seenByPeer.delete(peerId)
|
|
108
|
+
seenByPeer.set(peerId, msgId)
|
|
109
|
+
if (seenByPeer.size > MAX_PEERS) {
|
|
110
|
+
const oldest = seenByPeer.keys().next().value
|
|
111
|
+
if (oldest !== undefined) seenByPeer.delete(oldest)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
94
115
|
const listener = (event: { data: unknown }) => {
|
|
95
116
|
const msg = event.data as Partial<Message> | null
|
|
96
117
|
if (!msg || typeof msg !== 'object') return
|
|
@@ -102,7 +123,7 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
|
|
|
102
123
|
if (typeof msg.sourceId !== 'string' || typeof msg.msgId !== 'number') return
|
|
103
124
|
const last = seenByPeer.get(msg.sourceId) ?? -1
|
|
104
125
|
if (msg.msgId <= last) return
|
|
105
|
-
|
|
126
|
+
recordPeerMsg(msg.sourceId, msg.msgId)
|
|
106
127
|
|
|
107
128
|
if (msg.type === 'setData') {
|
|
108
129
|
if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {
|
|
@@ -136,8 +157,20 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
|
|
|
136
157
|
}
|
|
137
158
|
}
|
|
138
159
|
|
|
160
|
+
let initialized = false
|
|
139
161
|
return {
|
|
140
162
|
init(a) {
|
|
163
|
+
// The plugin instance owns one `sourceId`, one channel, and one
|
|
164
|
+
// listener Map. Sharing it across two roots would clobber that state
|
|
165
|
+
// on the second `init` and leak the first channel. Construct a fresh
|
|
166
|
+
// `crossTabPlugin({ ... })` per root.
|
|
167
|
+
if (initialized) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
'[olas/cross-tab] crossTabPlugin instance reused across multiple roots. ' +
|
|
170
|
+
'Each root must get its own `crossTabPlugin({ ... })`.',
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
initialized = true
|
|
141
174
|
api = a
|
|
142
175
|
channel = factory(channelName) ?? null
|
|
143
176
|
channel?.addEventListener('message', listener)
|
|
@@ -146,6 +179,11 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
|
|
|
146
179
|
onSetData(event: SetDataEvent) {
|
|
147
180
|
// Don't echo inbound writes — Layer-1 sender-side echo prevention.
|
|
148
181
|
if (event.isRemote) return
|
|
182
|
+
// Fetch-success writes are a per-tab concern: every tab runs its own
|
|
183
|
+
// fetcher and would otherwise rebroadcast results to peers that just
|
|
184
|
+
// fetched the same data themselves. We only echo explicit `setData`
|
|
185
|
+
// calls (mutations, optimistic patches, entity backprop) cross-tab.
|
|
186
|
+
if (event.source === 'fetch') return
|
|
149
187
|
// Infinite queries are deferred for v1 — see SPEC §13.2.
|
|
150
188
|
if (event.kind !== 'data') return
|
|
151
189
|
if (!shouldBroadcast(event.queryId)) return
|
|
@@ -188,6 +226,10 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
|
|
|
188
226
|
channel = null
|
|
189
227
|
}
|
|
190
228
|
api = null
|
|
229
|
+
seenByPeer.clear()
|
|
230
|
+
// Allow re-`init` only if a future runtime explicitly reattaches; today
|
|
231
|
+
// `QueryClient.dispose` is final, so this is mostly defensive.
|
|
232
|
+
initialized = false
|
|
191
233
|
},
|
|
192
234
|
}
|
|
193
235
|
}
|