@noy-db/by-tabs 0.1.0-pre.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/index.cjs +94 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +56 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vLannaAi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @noy-db/by-tabs
|
|
2
|
+
|
|
3
|
+
BroadcastChannel multi-tab transport for [noy-db](https://github.com/vLannaAi/noy-db) — sub-millisecond fan-out between tabs of the same origin.
|
|
4
|
+
|
|
5
|
+
> Second member of the `by-*` family of session-share transports. See [`docs/packages/by-transports.md`](../../docs/packages/by-transports.md) for the family contract and roster.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @noy-db/by-tabs @noy-db/by-peer @noy-db/hub
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
Three browser tabs of the same dashboard, all subscribed to the same vault. A `put` in tab A should surface in B and C without going round-trip through IndexedDB and back. `BroadcastChannel` is the native browser primitive for that fan-out — `@noy-db/by-tabs` wraps it as a `PeerChannel`, the same shape every other `by-*` transport implements.
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { tabsChannel, isTabsChannelAvailable } from '@noy-db/by-tabs'
|
|
21
|
+
import { peerStore, servePeerStore } from '@noy-db/by-peer'
|
|
22
|
+
|
|
23
|
+
if (!isTabsChannelAvailable()) {
|
|
24
|
+
// Node, SSR, or an older browser — fall back to direct store access.
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Tab A — proxy operations to Tab B's store.
|
|
29
|
+
const channel = tabsChannel({ name: 'noy-db:vault-acme' })
|
|
30
|
+
const remote = peerStore({ channel })
|
|
31
|
+
|
|
32
|
+
// Tab B — answer the RPC calls against its local store.
|
|
33
|
+
servePeerStore({ channel: tabsChannel({ name: 'noy-db:vault-acme' }), store: localStore })
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Threat model
|
|
37
|
+
|
|
38
|
+
- `BroadcastChannel` is **same-origin** by browser policy. Only documents on the same scheme + host + port can subscribe.
|
|
39
|
+
- noy-db already encrypts at rest — every tab on the channel sees AES-256-GCM ciphertext envelopes. The transport never decrypts.
|
|
40
|
+
- Treat the channel as **origin-scoped, not session-scoped** — untrusted iframes that share the origin can subscribe.
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
| Export | Shape |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `tabsChannel({ name })` | Returns a `PeerChannel` wrapping a fresh `BroadcastChannel`. |
|
|
47
|
+
| `isTabsChannelAvailable()` | Pre-flight: `true` when `BroadcastChannel` is defined. |
|
|
48
|
+
|
|
49
|
+
When `BroadcastChannel` is undefined the package returns a **no-op channel** so consumer code can import it on the server without crashing — `send()` will throw, `isOpen` stays `false`, and `on()` is a no-op subscribe.
|
|
50
|
+
|
|
51
|
+
## Status
|
|
52
|
+
|
|
53
|
+
`0.1.0-pre.1` — debut. Companion to `@noy-db/by-peer`.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
isTabsChannelAvailable: () => isTabsChannelAvailable,
|
|
24
|
+
tabsChannel: () => tabsChannel
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
function tabsChannel(options) {
|
|
28
|
+
const name = options.name;
|
|
29
|
+
const Bc = globalThis.BroadcastChannel;
|
|
30
|
+
if (!Bc) return makeNoopChannel(name);
|
|
31
|
+
const bc = new Bc(name);
|
|
32
|
+
const listeners = { message: /* @__PURE__ */ new Set(), close: /* @__PURE__ */ new Set() };
|
|
33
|
+
let closed = false;
|
|
34
|
+
bc.addEventListener("message", (ev) => {
|
|
35
|
+
if (typeof ev.data !== "string") return;
|
|
36
|
+
for (const fn of listeners.message) fn(ev.data);
|
|
37
|
+
});
|
|
38
|
+
function fireClose() {
|
|
39
|
+
if (closed) return;
|
|
40
|
+
closed = true;
|
|
41
|
+
for (const fn of listeners.close) fn();
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
get isOpen() {
|
|
45
|
+
return !closed;
|
|
46
|
+
},
|
|
47
|
+
send(payload) {
|
|
48
|
+
if (closed) throw new Error("PeerChannel closed");
|
|
49
|
+
bc.postMessage(payload);
|
|
50
|
+
},
|
|
51
|
+
on(event, listener) {
|
|
52
|
+
if (event === "message") {
|
|
53
|
+
listeners.message.add(listener);
|
|
54
|
+
return () => listeners.message.delete(listener);
|
|
55
|
+
}
|
|
56
|
+
listeners.close.add(listener);
|
|
57
|
+
return () => listeners.close.delete(listener);
|
|
58
|
+
},
|
|
59
|
+
close() {
|
|
60
|
+
if (closed) return;
|
|
61
|
+
try {
|
|
62
|
+
bc.close();
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
fireClose();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function isTabsChannelAvailable() {
|
|
70
|
+
return typeof globalThis.BroadcastChannel === "function";
|
|
71
|
+
}
|
|
72
|
+
function makeNoopChannel(name) {
|
|
73
|
+
void name;
|
|
74
|
+
return {
|
|
75
|
+
isOpen: false,
|
|
76
|
+
send() {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"[@noy-db/by-tabs] BroadcastChannel is not available in this runtime \u2014 use isTabsChannelAvailable() to feature-detect"
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
on(_event, _listener) {
|
|
82
|
+
return () => {
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
close() {
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
90
|
+
0 && (module.exports = {
|
|
91
|
+
isTabsChannelAvailable,
|
|
92
|
+
tabsChannel
|
|
93
|
+
});
|
|
94
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/by-tabs** — BroadcastChannel multi-tab transport for noy-db.\n *\n * Wraps the browser `BroadcastChannel` API as a `PeerChannel`, the\n * primitive every `by-*` transport implements. Sub-millisecond fan-out\n * across tabs of the same origin; same code shape as `@noy-db/by-peer`,\n * just a different wire.\n *\n * Compose with `peerStore()` / `servePeerStore()` from `@noy-db/by-peer`\n * for an asymmetric tab-as-remote-store topology, or use the channel\n * directly to publish change events to every other tab.\n *\n * ## Threat model\n *\n * - `BroadcastChannel` is same-origin by browser policy — only documents\n * served from the same scheme + host + port can subscribe.\n * - noy-db already encrypts at rest — every tab on the channel sees\n * only AES-256-GCM ciphertext envelopes. The transport never decrypts.\n * - Untrusted tabs (e.g. third-party iframes that share an origin via\n * `document.domain` legacy) can still see the channel. Treat\n * BroadcastChannel as origin-scoped, not session-scoped.\n *\n * @packageDocumentation\n */\n\nimport type { PeerChannel } from '@noy-db/by-peer'\n\nexport type { PeerChannel }\n\n/**\n * Options for `tabsChannel()`.\n */\nexport interface TabsChannelOptions {\n /**\n * BroadcastChannel name. Two tabs subscribed to the same name see\n * each other's messages. Use a stable, vault-scoped string like\n * `'noy-db:vault-acme'` to keep different vaults isolated.\n */\n readonly name: string\n}\n\n/**\n * Wrap a `BroadcastChannel` as a `PeerChannel`.\n *\n * Returns a no-op channel (always `isOpen: false`, no-op `send`) when\n * `BroadcastChannel` is undefined — so server-side rendering, Node\n * scripts, and older browsers can import the package without crashing\n * before they feature-detect.\n */\nexport function tabsChannel(options: TabsChannelOptions): PeerChannel {\n const name = options.name\n const Bc = (globalThis as { BroadcastChannel?: typeof BroadcastChannel }).BroadcastChannel\n if (!Bc) return makeNoopChannel(name)\n\n const bc = new Bc(name)\n type Listeners = {\n message: Set<(p: string) => void>\n close: Set<() => void>\n }\n const listeners: Listeners = { message: new Set(), close: new Set() }\n let closed = false\n\n bc.addEventListener('message', (ev: MessageEvent) => {\n if (typeof ev.data !== 'string') return\n for (const fn of listeners.message) fn(ev.data)\n })\n\n function fireClose(): void {\n if (closed) return\n closed = true\n for (const fn of listeners.close) fn()\n }\n\n return {\n get isOpen() {\n return !closed\n },\n send(payload: string) {\n if (closed) throw new Error('PeerChannel closed')\n bc.postMessage(payload)\n },\n on(event: 'message' | 'close', listener: ((payload: string) => void) | (() => void)): () => void {\n if (event === 'message') {\n listeners.message.add(listener as (p: string) => void)\n return () => listeners.message.delete(listener as (p: string) => void)\n }\n listeners.close.add(listener as () => void)\n return () => listeners.close.delete(listener as () => void)\n },\n close() {\n if (closed) return\n try {\n bc.close()\n } catch {\n // ignore — channel already torn down\n }\n fireClose()\n },\n } as PeerChannel\n}\n\n/**\n * Whether `BroadcastChannel` is available in the current runtime. Use\n * this as a pre-flight before rendering UI that relies on tab-to-tab\n * sync — Node, SSR, and a handful of older browsers don't ship it.\n */\nexport function isTabsChannelAvailable(): boolean {\n return typeof (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel === 'function'\n}\n\n// ─── Internals ───────────────────────────────────────────────────────────\n\nfunction makeNoopChannel(name: string): PeerChannel {\n void name\n return {\n isOpen: false,\n send() {\n throw new Error(\n '[@noy-db/by-tabs] BroadcastChannel is not available in this runtime — use isTabsChannelAvailable() to feature-detect',\n )\n },\n on(_event: 'message' | 'close', _listener: ((p: string) => void) | (() => void)): () => void {\n return () => {}\n },\n close() {},\n } as PeerChannel\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiDO,SAAS,YAAY,SAA0C;AACpE,QAAM,OAAO,QAAQ;AACrB,QAAM,KAAM,WAA8D;AAC1E,MAAI,CAAC,GAAI,QAAO,gBAAgB,IAAI;AAEpC,QAAM,KAAK,IAAI,GAAG,IAAI;AAKtB,QAAM,YAAuB,EAAE,SAAS,oBAAI,IAAI,GAAG,OAAO,oBAAI,IAAI,EAAE;AACpE,MAAI,SAAS;AAEb,KAAG,iBAAiB,WAAW,CAAC,OAAqB;AACnD,QAAI,OAAO,GAAG,SAAS,SAAU;AACjC,eAAW,MAAM,UAAU,QAAS,IAAG,GAAG,IAAI;AAAA,EAChD,CAAC;AAED,WAAS,YAAkB;AACzB,QAAI,OAAQ;AACZ,aAAS;AACT,eAAW,MAAM,UAAU,MAAO,IAAG;AAAA,EACvC;AAEA,SAAO;AAAA,IACL,IAAI,SAAS;AACX,aAAO,CAAC;AAAA,IACV;AAAA,IACA,KAAK,SAAiB;AACpB,UAAI,OAAQ,OAAM,IAAI,MAAM,oBAAoB;AAChD,SAAG,YAAY,OAAO;AAAA,IACxB;AAAA,IACA,GAAG,OAA4B,UAAkE;AAC/F,UAAI,UAAU,WAAW;AACvB,kBAAU,QAAQ,IAAI,QAA+B;AACrD,eAAO,MAAM,UAAU,QAAQ,OAAO,QAA+B;AAAA,MACvE;AACA,gBAAU,MAAM,IAAI,QAAsB;AAC1C,aAAO,MAAM,UAAU,MAAM,OAAO,QAAsB;AAAA,IAC5D;AAAA,IACA,QAAQ;AACN,UAAI,OAAQ;AACZ,UAAI;AACF,WAAG,MAAM;AAAA,MACX,QAAQ;AAAA,MAER;AACA,gBAAU;AAAA,IACZ;AAAA,EACF;AACF;AAOO,SAAS,yBAAkC;AAChD,SAAO,OAAQ,WAA8C,qBAAqB;AACpF;AAIA,SAAS,gBAAgB,MAA2B;AAClD,OAAK;AACL,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,OAAO;AACL,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,IACA,GAAG,QAA6B,WAA6D;AAC3F,aAAO,MAAM;AAAA,MAAC;AAAA,IAChB;AAAA,IACA,QAAQ;AAAA,IAAC;AAAA,EACX;AACF;","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { PeerChannel } from '@noy-db/by-peer';
|
|
2
|
+
export { PeerChannel } from '@noy-db/by-peer';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* **@noy-db/by-tabs** — BroadcastChannel multi-tab transport for noy-db.
|
|
6
|
+
*
|
|
7
|
+
* Wraps the browser `BroadcastChannel` API as a `PeerChannel`, the
|
|
8
|
+
* primitive every `by-*` transport implements. Sub-millisecond fan-out
|
|
9
|
+
* across tabs of the same origin; same code shape as `@noy-db/by-peer`,
|
|
10
|
+
* just a different wire.
|
|
11
|
+
*
|
|
12
|
+
* Compose with `peerStore()` / `servePeerStore()` from `@noy-db/by-peer`
|
|
13
|
+
* for an asymmetric tab-as-remote-store topology, or use the channel
|
|
14
|
+
* directly to publish change events to every other tab.
|
|
15
|
+
*
|
|
16
|
+
* ## Threat model
|
|
17
|
+
*
|
|
18
|
+
* - `BroadcastChannel` is same-origin by browser policy — only documents
|
|
19
|
+
* served from the same scheme + host + port can subscribe.
|
|
20
|
+
* - noy-db already encrypts at rest — every tab on the channel sees
|
|
21
|
+
* only AES-256-GCM ciphertext envelopes. The transport never decrypts.
|
|
22
|
+
* - Untrusted tabs (e.g. third-party iframes that share an origin via
|
|
23
|
+
* `document.domain` legacy) can still see the channel. Treat
|
|
24
|
+
* BroadcastChannel as origin-scoped, not session-scoped.
|
|
25
|
+
*
|
|
26
|
+
* @packageDocumentation
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Options for `tabsChannel()`.
|
|
31
|
+
*/
|
|
32
|
+
interface TabsChannelOptions {
|
|
33
|
+
/**
|
|
34
|
+
* BroadcastChannel name. Two tabs subscribed to the same name see
|
|
35
|
+
* each other's messages. Use a stable, vault-scoped string like
|
|
36
|
+
* `'noy-db:vault-acme'` to keep different vaults isolated.
|
|
37
|
+
*/
|
|
38
|
+
readonly name: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Wrap a `BroadcastChannel` as a `PeerChannel`.
|
|
42
|
+
*
|
|
43
|
+
* Returns a no-op channel (always `isOpen: false`, no-op `send`) when
|
|
44
|
+
* `BroadcastChannel` is undefined — so server-side rendering, Node
|
|
45
|
+
* scripts, and older browsers can import the package without crashing
|
|
46
|
+
* before they feature-detect.
|
|
47
|
+
*/
|
|
48
|
+
declare function tabsChannel(options: TabsChannelOptions): PeerChannel;
|
|
49
|
+
/**
|
|
50
|
+
* Whether `BroadcastChannel` is available in the current runtime. Use
|
|
51
|
+
* this as a pre-flight before rendering UI that relies on tab-to-tab
|
|
52
|
+
* sync — Node, SSR, and a handful of older browsers don't ship it.
|
|
53
|
+
*/
|
|
54
|
+
declare function isTabsChannelAvailable(): boolean;
|
|
55
|
+
|
|
56
|
+
export { type TabsChannelOptions, isTabsChannelAvailable, tabsChannel };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { PeerChannel } from '@noy-db/by-peer';
|
|
2
|
+
export { PeerChannel } from '@noy-db/by-peer';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* **@noy-db/by-tabs** — BroadcastChannel multi-tab transport for noy-db.
|
|
6
|
+
*
|
|
7
|
+
* Wraps the browser `BroadcastChannel` API as a `PeerChannel`, the
|
|
8
|
+
* primitive every `by-*` transport implements. Sub-millisecond fan-out
|
|
9
|
+
* across tabs of the same origin; same code shape as `@noy-db/by-peer`,
|
|
10
|
+
* just a different wire.
|
|
11
|
+
*
|
|
12
|
+
* Compose with `peerStore()` / `servePeerStore()` from `@noy-db/by-peer`
|
|
13
|
+
* for an asymmetric tab-as-remote-store topology, or use the channel
|
|
14
|
+
* directly to publish change events to every other tab.
|
|
15
|
+
*
|
|
16
|
+
* ## Threat model
|
|
17
|
+
*
|
|
18
|
+
* - `BroadcastChannel` is same-origin by browser policy — only documents
|
|
19
|
+
* served from the same scheme + host + port can subscribe.
|
|
20
|
+
* - noy-db already encrypts at rest — every tab on the channel sees
|
|
21
|
+
* only AES-256-GCM ciphertext envelopes. The transport never decrypts.
|
|
22
|
+
* - Untrusted tabs (e.g. third-party iframes that share an origin via
|
|
23
|
+
* `document.domain` legacy) can still see the channel. Treat
|
|
24
|
+
* BroadcastChannel as origin-scoped, not session-scoped.
|
|
25
|
+
*
|
|
26
|
+
* @packageDocumentation
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Options for `tabsChannel()`.
|
|
31
|
+
*/
|
|
32
|
+
interface TabsChannelOptions {
|
|
33
|
+
/**
|
|
34
|
+
* BroadcastChannel name. Two tabs subscribed to the same name see
|
|
35
|
+
* each other's messages. Use a stable, vault-scoped string like
|
|
36
|
+
* `'noy-db:vault-acme'` to keep different vaults isolated.
|
|
37
|
+
*/
|
|
38
|
+
readonly name: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Wrap a `BroadcastChannel` as a `PeerChannel`.
|
|
42
|
+
*
|
|
43
|
+
* Returns a no-op channel (always `isOpen: false`, no-op `send`) when
|
|
44
|
+
* `BroadcastChannel` is undefined — so server-side rendering, Node
|
|
45
|
+
* scripts, and older browsers can import the package without crashing
|
|
46
|
+
* before they feature-detect.
|
|
47
|
+
*/
|
|
48
|
+
declare function tabsChannel(options: TabsChannelOptions): PeerChannel;
|
|
49
|
+
/**
|
|
50
|
+
* Whether `BroadcastChannel` is available in the current runtime. Use
|
|
51
|
+
* this as a pre-flight before rendering UI that relies on tab-to-tab
|
|
52
|
+
* sync — Node, SSR, and a handful of older browsers don't ship it.
|
|
53
|
+
*/
|
|
54
|
+
declare function isTabsChannelAvailable(): boolean;
|
|
55
|
+
|
|
56
|
+
export { type TabsChannelOptions, isTabsChannelAvailable, tabsChannel };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function tabsChannel(options) {
|
|
3
|
+
const name = options.name;
|
|
4
|
+
const Bc = globalThis.BroadcastChannel;
|
|
5
|
+
if (!Bc) return makeNoopChannel(name);
|
|
6
|
+
const bc = new Bc(name);
|
|
7
|
+
const listeners = { message: /* @__PURE__ */ new Set(), close: /* @__PURE__ */ new Set() };
|
|
8
|
+
let closed = false;
|
|
9
|
+
bc.addEventListener("message", (ev) => {
|
|
10
|
+
if (typeof ev.data !== "string") return;
|
|
11
|
+
for (const fn of listeners.message) fn(ev.data);
|
|
12
|
+
});
|
|
13
|
+
function fireClose() {
|
|
14
|
+
if (closed) return;
|
|
15
|
+
closed = true;
|
|
16
|
+
for (const fn of listeners.close) fn();
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
get isOpen() {
|
|
20
|
+
return !closed;
|
|
21
|
+
},
|
|
22
|
+
send(payload) {
|
|
23
|
+
if (closed) throw new Error("PeerChannel closed");
|
|
24
|
+
bc.postMessage(payload);
|
|
25
|
+
},
|
|
26
|
+
on(event, listener) {
|
|
27
|
+
if (event === "message") {
|
|
28
|
+
listeners.message.add(listener);
|
|
29
|
+
return () => listeners.message.delete(listener);
|
|
30
|
+
}
|
|
31
|
+
listeners.close.add(listener);
|
|
32
|
+
return () => listeners.close.delete(listener);
|
|
33
|
+
},
|
|
34
|
+
close() {
|
|
35
|
+
if (closed) return;
|
|
36
|
+
try {
|
|
37
|
+
bc.close();
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
fireClose();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function isTabsChannelAvailable() {
|
|
45
|
+
return typeof globalThis.BroadcastChannel === "function";
|
|
46
|
+
}
|
|
47
|
+
function makeNoopChannel(name) {
|
|
48
|
+
void name;
|
|
49
|
+
return {
|
|
50
|
+
isOpen: false,
|
|
51
|
+
send() {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"[@noy-db/by-tabs] BroadcastChannel is not available in this runtime \u2014 use isTabsChannelAvailable() to feature-detect"
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
on(_event, _listener) {
|
|
57
|
+
return () => {
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
close() {
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export {
|
|
65
|
+
isTabsChannelAvailable,
|
|
66
|
+
tabsChannel
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/by-tabs** — BroadcastChannel multi-tab transport for noy-db.\n *\n * Wraps the browser `BroadcastChannel` API as a `PeerChannel`, the\n * primitive every `by-*` transport implements. Sub-millisecond fan-out\n * across tabs of the same origin; same code shape as `@noy-db/by-peer`,\n * just a different wire.\n *\n * Compose with `peerStore()` / `servePeerStore()` from `@noy-db/by-peer`\n * for an asymmetric tab-as-remote-store topology, or use the channel\n * directly to publish change events to every other tab.\n *\n * ## Threat model\n *\n * - `BroadcastChannel` is same-origin by browser policy — only documents\n * served from the same scheme + host + port can subscribe.\n * - noy-db already encrypts at rest — every tab on the channel sees\n * only AES-256-GCM ciphertext envelopes. The transport never decrypts.\n * - Untrusted tabs (e.g. third-party iframes that share an origin via\n * `document.domain` legacy) can still see the channel. Treat\n * BroadcastChannel as origin-scoped, not session-scoped.\n *\n * @packageDocumentation\n */\n\nimport type { PeerChannel } from '@noy-db/by-peer'\n\nexport type { PeerChannel }\n\n/**\n * Options for `tabsChannel()`.\n */\nexport interface TabsChannelOptions {\n /**\n * BroadcastChannel name. Two tabs subscribed to the same name see\n * each other's messages. Use a stable, vault-scoped string like\n * `'noy-db:vault-acme'` to keep different vaults isolated.\n */\n readonly name: string\n}\n\n/**\n * Wrap a `BroadcastChannel` as a `PeerChannel`.\n *\n * Returns a no-op channel (always `isOpen: false`, no-op `send`) when\n * `BroadcastChannel` is undefined — so server-side rendering, Node\n * scripts, and older browsers can import the package without crashing\n * before they feature-detect.\n */\nexport function tabsChannel(options: TabsChannelOptions): PeerChannel {\n const name = options.name\n const Bc = (globalThis as { BroadcastChannel?: typeof BroadcastChannel }).BroadcastChannel\n if (!Bc) return makeNoopChannel(name)\n\n const bc = new Bc(name)\n type Listeners = {\n message: Set<(p: string) => void>\n close: Set<() => void>\n }\n const listeners: Listeners = { message: new Set(), close: new Set() }\n let closed = false\n\n bc.addEventListener('message', (ev: MessageEvent) => {\n if (typeof ev.data !== 'string') return\n for (const fn of listeners.message) fn(ev.data)\n })\n\n function fireClose(): void {\n if (closed) return\n closed = true\n for (const fn of listeners.close) fn()\n }\n\n return {\n get isOpen() {\n return !closed\n },\n send(payload: string) {\n if (closed) throw new Error('PeerChannel closed')\n bc.postMessage(payload)\n },\n on(event: 'message' | 'close', listener: ((payload: string) => void) | (() => void)): () => void {\n if (event === 'message') {\n listeners.message.add(listener as (p: string) => void)\n return () => listeners.message.delete(listener as (p: string) => void)\n }\n listeners.close.add(listener as () => void)\n return () => listeners.close.delete(listener as () => void)\n },\n close() {\n if (closed) return\n try {\n bc.close()\n } catch {\n // ignore — channel already torn down\n }\n fireClose()\n },\n } as PeerChannel\n}\n\n/**\n * Whether `BroadcastChannel` is available in the current runtime. Use\n * this as a pre-flight before rendering UI that relies on tab-to-tab\n * sync — Node, SSR, and a handful of older browsers don't ship it.\n */\nexport function isTabsChannelAvailable(): boolean {\n return typeof (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel === 'function'\n}\n\n// ─── Internals ───────────────────────────────────────────────────────────\n\nfunction makeNoopChannel(name: string): PeerChannel {\n void name\n return {\n isOpen: false,\n send() {\n throw new Error(\n '[@noy-db/by-tabs] BroadcastChannel is not available in this runtime — use isTabsChannelAvailable() to feature-detect',\n )\n },\n on(_event: 'message' | 'close', _listener: ((p: string) => void) | (() => void)): () => void {\n return () => {}\n },\n close() {},\n } as PeerChannel\n}\n"],"mappings":";AAiDO,SAAS,YAAY,SAA0C;AACpE,QAAM,OAAO,QAAQ;AACrB,QAAM,KAAM,WAA8D;AAC1E,MAAI,CAAC,GAAI,QAAO,gBAAgB,IAAI;AAEpC,QAAM,KAAK,IAAI,GAAG,IAAI;AAKtB,QAAM,YAAuB,EAAE,SAAS,oBAAI,IAAI,GAAG,OAAO,oBAAI,IAAI,EAAE;AACpE,MAAI,SAAS;AAEb,KAAG,iBAAiB,WAAW,CAAC,OAAqB;AACnD,QAAI,OAAO,GAAG,SAAS,SAAU;AACjC,eAAW,MAAM,UAAU,QAAS,IAAG,GAAG,IAAI;AAAA,EAChD,CAAC;AAED,WAAS,YAAkB;AACzB,QAAI,OAAQ;AACZ,aAAS;AACT,eAAW,MAAM,UAAU,MAAO,IAAG;AAAA,EACvC;AAEA,SAAO;AAAA,IACL,IAAI,SAAS;AACX,aAAO,CAAC;AAAA,IACV;AAAA,IACA,KAAK,SAAiB;AACpB,UAAI,OAAQ,OAAM,IAAI,MAAM,oBAAoB;AAChD,SAAG,YAAY,OAAO;AAAA,IACxB;AAAA,IACA,GAAG,OAA4B,UAAkE;AAC/F,UAAI,UAAU,WAAW;AACvB,kBAAU,QAAQ,IAAI,QAA+B;AACrD,eAAO,MAAM,UAAU,QAAQ,OAAO,QAA+B;AAAA,MACvE;AACA,gBAAU,MAAM,IAAI,QAAsB;AAC1C,aAAO,MAAM,UAAU,MAAM,OAAO,QAAsB;AAAA,IAC5D;AAAA,IACA,QAAQ;AACN,UAAI,OAAQ;AACZ,UAAI;AACF,WAAG,MAAM;AAAA,MACX,QAAQ;AAAA,MAER;AACA,gBAAU;AAAA,IACZ;AAAA,EACF;AACF;AAOO,SAAS,yBAAkC;AAChD,SAAO,OAAQ,WAA8C,qBAAqB;AACpF;AAIA,SAAS,gBAAgB,MAA2B;AAClD,OAAK;AACL,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,OAAO;AACL,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,IACA,GAAG,QAA6B,WAA6D;AAC3F,aAAO,MAAM;AAAA,MAAC;AAAA,IAChB;AAAA,IACA,QAAQ;AAAA,IAAC;AAAA,EACX;AACF;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noy-db/by-tabs",
|
|
3
|
+
"version": "0.1.0-pre.3",
|
|
4
|
+
"description": "BroadcastChannel multi-tab transport for noy-db — sync between tabs of the same browser without round-tripping the storage backend",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "vLannaAi <vicio@lanna.ai>",
|
|
7
|
+
"homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/by-tabs#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vLannaAi/noy-db.git",
|
|
11
|
+
"directory": "packages/by-tabs"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/vLannaAi/noy-db/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"require": {
|
|
25
|
+
"types": "./dist/index.d.cts",
|
|
26
|
+
"default": "./dist/index.cjs"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.cjs",
|
|
31
|
+
"module": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@noy-db/by-peer": "0.1.0-pre.3",
|
|
43
|
+
"@noy-db/hub": "0.1.0-pre.3"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@noy-db/by-peer": "0.1.0-pre.3",
|
|
47
|
+
"@noy-db/to-memory": "0.1.0-pre.3",
|
|
48
|
+
"@noy-db/hub": "0.1.0-pre.3"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"noy-db",
|
|
52
|
+
"by-tabs",
|
|
53
|
+
"broadcast-channel",
|
|
54
|
+
"multi-tab",
|
|
55
|
+
"tab-sync",
|
|
56
|
+
"browser",
|
|
57
|
+
"realtime",
|
|
58
|
+
"sync",
|
|
59
|
+
"encryption",
|
|
60
|
+
"zero-knowledge"
|
|
61
|
+
],
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public",
|
|
64
|
+
"tag": "latest"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsup",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"lint": "eslint src/",
|
|
70
|
+
"typecheck": "tsc --noEmit"
|
|
71
|
+
}
|
|
72
|
+
}
|