@kontsedal/olas-cross-tab 0.0.1-rc.1 → 0.0.1
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 +5 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +5 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/plugin.ts +20 -0
package/dist/index.cjs
CHANGED
|
@@ -131,14 +131,18 @@ function crossTabPlugin(options) {
|
|
|
131
131
|
onWarn(`[olas/cross-tab] failed to broadcast ${msg.type} for queryId="${msg.queryId}": data is not structured-cloneable`, cause);
|
|
132
132
|
}
|
|
133
133
|
};
|
|
134
|
+
let initialized = false;
|
|
134
135
|
return {
|
|
135
136
|
init(a) {
|
|
137
|
+
if (initialized) throw new Error("[olas/cross-tab] crossTabPlugin instance reused across multiple roots. Each root must get its own `crossTabPlugin({ ... })`.");
|
|
138
|
+
initialized = true;
|
|
136
139
|
api = a;
|
|
137
140
|
channel = factory(channelName) ?? null;
|
|
138
141
|
channel?.addEventListener("message", listener);
|
|
139
142
|
},
|
|
140
143
|
onSetData(event) {
|
|
141
144
|
if (event.isRemote) return;
|
|
145
|
+
if (event.source === "fetch") return;
|
|
142
146
|
if (event.kind !== "data") return;
|
|
143
147
|
if (!shouldBroadcast(event.queryId)) return;
|
|
144
148
|
send({
|
|
@@ -172,6 +176,7 @@ function crossTabPlugin(options) {
|
|
|
172
176
|
channel = null;
|
|
173
177
|
}
|
|
174
178
|
api = null;
|
|
179
|
+
initialized = false;
|
|
175
180
|
}
|
|
176
181
|
};
|
|
177
182
|
}
|
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 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 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 // 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
|
@@ -130,14 +130,18 @@ function crossTabPlugin(options) {
|
|
|
130
130
|
onWarn(`[olas/cross-tab] failed to broadcast ${msg.type} for queryId="${msg.queryId}": data is not structured-cloneable`, cause);
|
|
131
131
|
}
|
|
132
132
|
};
|
|
133
|
+
let initialized = false;
|
|
133
134
|
return {
|
|
134
135
|
init(a) {
|
|
136
|
+
if (initialized) throw new Error("[olas/cross-tab] crossTabPlugin instance reused across multiple roots. Each root must get its own `crossTabPlugin({ ... })`.");
|
|
137
|
+
initialized = true;
|
|
135
138
|
api = a;
|
|
136
139
|
channel = factory(channelName) ?? null;
|
|
137
140
|
channel?.addEventListener("message", listener);
|
|
138
141
|
},
|
|
139
142
|
onSetData(event) {
|
|
140
143
|
if (event.isRemote) return;
|
|
144
|
+
if (event.source === "fetch") return;
|
|
141
145
|
if (event.kind !== "data") return;
|
|
142
146
|
if (!shouldBroadcast(event.queryId)) return;
|
|
143
147
|
send({
|
|
@@ -171,6 +175,7 @@ function crossTabPlugin(options) {
|
|
|
171
175
|
channel = null;
|
|
172
176
|
}
|
|
173
177
|
api = null;
|
|
178
|
+
initialized = false;
|
|
174
179
|
}
|
|
175
180
|
};
|
|
176
181
|
}
|
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 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 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 // 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
|
|
3
|
+
"version": "0.0.1",
|
|
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
|
|
48
|
+
"@kontsedal/olas-core": "^0.0.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@kontsedal/olas-core": "^0.0.1
|
|
51
|
+
"@kontsedal/olas-core": "^0.0.1"
|
|
52
52
|
},
|
|
53
53
|
"publishConfig": {
|
|
54
54
|
"access": "public"
|
package/src/plugin.ts
CHANGED
|
@@ -136,8 +136,20 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
let initialized = false
|
|
139
140
|
return {
|
|
140
141
|
init(a) {
|
|
142
|
+
// The plugin instance owns one `sourceId`, one channel, and one
|
|
143
|
+
// listener Map. Sharing it across two roots would clobber that state
|
|
144
|
+
// on the second `init` and leak the first channel. Construct a fresh
|
|
145
|
+
// `crossTabPlugin({ ... })` per root.
|
|
146
|
+
if (initialized) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'[olas/cross-tab] crossTabPlugin instance reused across multiple roots. ' +
|
|
149
|
+
'Each root must get its own `crossTabPlugin({ ... })`.',
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
initialized = true
|
|
141
153
|
api = a
|
|
142
154
|
channel = factory(channelName) ?? null
|
|
143
155
|
channel?.addEventListener('message', listener)
|
|
@@ -146,6 +158,11 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
|
|
|
146
158
|
onSetData(event: SetDataEvent) {
|
|
147
159
|
// Don't echo inbound writes — Layer-1 sender-side echo prevention.
|
|
148
160
|
if (event.isRemote) return
|
|
161
|
+
// Fetch-success writes are a per-tab concern: every tab runs its own
|
|
162
|
+
// fetcher and would otherwise rebroadcast results to peers that just
|
|
163
|
+
// fetched the same data themselves. We only echo explicit `setData`
|
|
164
|
+
// calls (mutations, optimistic patches, entity backprop) cross-tab.
|
|
165
|
+
if (event.source === 'fetch') return
|
|
149
166
|
// Infinite queries are deferred for v1 — see SPEC §13.2.
|
|
150
167
|
if (event.kind !== 'data') return
|
|
151
168
|
if (!shouldBroadcast(event.queryId)) return
|
|
@@ -188,6 +205,9 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
|
|
|
188
205
|
channel = null
|
|
189
206
|
}
|
|
190
207
|
api = null
|
|
208
|
+
// Allow re-`init` only if a future runtime explicitly reattaches; today
|
|
209
|
+
// `QueryClient.dispose` is final, so this is mostly defensive.
|
|
210
|
+
initialized = false
|
|
191
211
|
},
|
|
192
212
|
}
|
|
193
213
|
}
|