@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 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
- seenByPeer.set(msg.sourceId, msg.msgId);
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
  }
@@ -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
- seenByPeer.set(msg.sourceId, msg.msgId);
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
  }
@@ -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.1-rc.1",
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.1-rc.1"
48
+ "@kontsedal/olas-core": "^0.0.2"
49
49
  },
50
50
  "devDependencies": {
51
- "@kontsedal/olas-core": "^0.0.1-rc.1"
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
- seenByPeer.set(msg.sourceId, msg.msgId)
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
  }