@canvas-harness/sync-broadcast 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ // src/index.ts
4
+ var createBroadcastSyncAdapter = ({
5
+ channelName,
6
+ clientId,
7
+ initialPresence
8
+ }) => {
9
+ if (typeof BroadcastChannel === "undefined") {
10
+ throw new Error("BroadcastChannel is not available in this environment.");
11
+ }
12
+ const channel = new BroadcastChannel(channelName);
13
+ const batchListeners = /* @__PURE__ */ new Set();
14
+ const presenceListeners = /* @__PURE__ */ new Set();
15
+ let lastLocalPresence = initialPresence;
16
+ const post = (msg) => {
17
+ channel.postMessage(msg);
18
+ };
19
+ channel.addEventListener("message", (e) => {
20
+ const msg = e.data;
21
+ if (msg.kind === "batch") {
22
+ if (msg.batch.clientId === clientId) return;
23
+ for (const cb of batchListeners) cb(msg.batch);
24
+ return;
25
+ }
26
+ if (msg.kind === "presence") {
27
+ if (msg.clientId === clientId) return;
28
+ for (const cb of presenceListeners) cb(msg.clientId, msg.state);
29
+ return;
30
+ }
31
+ if (msg.kind === "presence-leave") {
32
+ if (msg.clientId === clientId) return;
33
+ for (const cb of presenceListeners) cb(msg.clientId, null);
34
+ return;
35
+ }
36
+ if (msg.kind === "hello" && msg.clientId !== clientId && lastLocalPresence) {
37
+ post({ kind: "presence", clientId, state: lastLocalPresence });
38
+ }
39
+ });
40
+ post({ kind: "hello", clientId });
41
+ const onPageHide = () => {
42
+ post({ kind: "presence-leave", clientId });
43
+ };
44
+ if (typeof window !== "undefined") window.addEventListener("pagehide", onPageHide);
45
+ return {
46
+ capabilities: { causalOrdering: true },
47
+ sendBatch(batch) {
48
+ post({ kind: "batch", batch });
49
+ },
50
+ sendPresence(patch) {
51
+ const state = {
52
+ ...lastLocalPresence ?? {},
53
+ ...patch,
54
+ clientId
55
+ };
56
+ lastLocalPresence = state;
57
+ post({ kind: "presence", clientId, state });
58
+ },
59
+ onBatch(cb) {
60
+ batchListeners.add(cb);
61
+ return () => {
62
+ batchListeners.delete(cb);
63
+ };
64
+ },
65
+ onPresence(cb) {
66
+ presenceListeners.add(cb);
67
+ return () => {
68
+ presenceListeners.delete(cb);
69
+ };
70
+ },
71
+ destroy() {
72
+ if (typeof window !== "undefined") window.removeEventListener("pagehide", onPageHide);
73
+ post({ kind: "presence-leave", clientId });
74
+ channel.close();
75
+ batchListeners.clear();
76
+ presenceListeners.clear();
77
+ }
78
+ };
79
+ };
80
+
81
+ exports.createBroadcastSyncAdapter = createBroadcastSyncAdapter;
82
+ //# sourceMappingURL=index.cjs.map
83
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type {\n ClientId,\n OpBatch,\n PresencePatch,\n PresenceState,\n SyncAdapter,\n Unsubscribe,\n} from '@canvas-harness/core'\n\n/**\n * BroadcastChannel-backed SyncAdapter — see ARCHITECTURE.md §10.6.\n *\n * The simplest possible adapter: serializes op batches + presence\n * patches to JSON and broadcasts over the BroadcastChannel API. Same\n * machine, multiple tabs of the same origin. Good for demos + dev.\n *\n * Causal-ordering guarantee: BroadcastChannel within a single browser\n * process delivers messages in send order, so we can advertise\n * `capabilities.causalOrdering: true`. For cross-machine / cross-origin\n * collab use a real transport (WebSocket + sequenced server, Yjs, etc.).\n */\n\ntype Wire =\n | { kind: 'batch'; batch: OpBatch }\n | { kind: 'presence'; clientId: ClientId; state: PresenceState }\n | { kind: 'presence-leave'; clientId: ClientId }\n | { kind: 'hello'; clientId: ClientId }\n\nexport type BroadcastSyncOptions = {\n /** Channel name — peers join by sharing the same name. */\n channelName: string\n /** This client's id. Used to filter self-echoes + announce on attach. */\n clientId: ClientId\n /** Optional initial presence to broadcast on attach. */\n initialPresence?: PresenceState\n}\n\nexport const createBroadcastSyncAdapter = ({\n channelName,\n clientId,\n initialPresence,\n}: BroadcastSyncOptions): SyncAdapter => {\n if (typeof BroadcastChannel === 'undefined') {\n throw new Error('BroadcastChannel is not available in this environment.')\n }\n const channel = new BroadcastChannel(channelName)\n\n const batchListeners = new Set<(batch: OpBatch) => void>()\n const presenceListeners = new Set<(clientId: ClientId, state: PresenceState | null) => void>()\n // Latest presence per remote client. We keep a snapshot so when a new\n // peer announces 'hello' we can replay our own presence to them.\n let lastLocalPresence: PresenceState | undefined = initialPresence\n\n const post = (msg: Wire): void => {\n channel.postMessage(msg)\n }\n\n channel.addEventListener('message', e => {\n const msg = e.data as Wire\n // BroadcastChannel doesn't echo to the sender, but in case a future\n // transport changes that, defensively drop self-messages.\n if (msg.kind === 'batch') {\n if (msg.batch.clientId === clientId) return\n for (const cb of batchListeners) cb(msg.batch)\n return\n }\n if (msg.kind === 'presence') {\n if (msg.clientId === clientId) return\n for (const cb of presenceListeners) cb(msg.clientId, msg.state)\n return\n }\n if (msg.kind === 'presence-leave') {\n if (msg.clientId === clientId) return\n for (const cb of presenceListeners) cb(msg.clientId, null)\n return\n }\n if (msg.kind === 'hello' && msg.clientId !== clientId && lastLocalPresence) {\n // A new peer joined; replay our presence so they can paint our cursor.\n post({ kind: 'presence', clientId, state: lastLocalPresence })\n }\n })\n\n // Announce arrival so existing peers can replay their presence to us.\n post({ kind: 'hello', clientId })\n\n // Best-effort departure: BroadcastChannel doesn't have a leave event,\n // but pagehide is close enough for a demo.\n const onPageHide = (): void => {\n post({ kind: 'presence-leave', clientId })\n }\n if (typeof window !== 'undefined') window.addEventListener('pagehide', onPageHide)\n\n return {\n capabilities: { causalOrdering: true },\n\n sendBatch(batch: OpBatch) {\n post({ kind: 'batch', batch })\n },\n\n sendPresence(patch: PresencePatch) {\n const state: PresenceState = {\n ...(lastLocalPresence ?? ({} as PresenceState)),\n ...patch,\n clientId,\n }\n lastLocalPresence = state\n post({ kind: 'presence', clientId, state })\n },\n\n onBatch(cb): Unsubscribe {\n batchListeners.add(cb)\n return () => {\n batchListeners.delete(cb)\n }\n },\n\n onPresence(cb): Unsubscribe {\n presenceListeners.add(cb)\n return () => {\n presenceListeners.delete(cb)\n }\n },\n\n destroy() {\n if (typeof window !== 'undefined') window.removeEventListener('pagehide', onPageHide)\n post({ kind: 'presence-leave', clientId })\n channel.close()\n batchListeners.clear()\n presenceListeners.clear()\n },\n }\n}\n"]}
@@ -0,0 +1,13 @@
1
+ import { ClientId, PresenceState, SyncAdapter } from '@canvas-harness/core';
2
+
3
+ type BroadcastSyncOptions = {
4
+ /** Channel name — peers join by sharing the same name. */
5
+ channelName: string;
6
+ /** This client's id. Used to filter self-echoes + announce on attach. */
7
+ clientId: ClientId;
8
+ /** Optional initial presence to broadcast on attach. */
9
+ initialPresence?: PresenceState;
10
+ };
11
+ declare const createBroadcastSyncAdapter: ({ channelName, clientId, initialPresence, }: BroadcastSyncOptions) => SyncAdapter;
12
+
13
+ export { type BroadcastSyncOptions, createBroadcastSyncAdapter };
@@ -0,0 +1,13 @@
1
+ import { ClientId, PresenceState, SyncAdapter } from '@canvas-harness/core';
2
+
3
+ type BroadcastSyncOptions = {
4
+ /** Channel name — peers join by sharing the same name. */
5
+ channelName: string;
6
+ /** This client's id. Used to filter self-echoes + announce on attach. */
7
+ clientId: ClientId;
8
+ /** Optional initial presence to broadcast on attach. */
9
+ initialPresence?: PresenceState;
10
+ };
11
+ declare const createBroadcastSyncAdapter: ({ channelName, clientId, initialPresence, }: BroadcastSyncOptions) => SyncAdapter;
12
+
13
+ export { type BroadcastSyncOptions, createBroadcastSyncAdapter };
package/dist/index.js ADDED
@@ -0,0 +1,81 @@
1
+ // src/index.ts
2
+ var createBroadcastSyncAdapter = ({
3
+ channelName,
4
+ clientId,
5
+ initialPresence
6
+ }) => {
7
+ if (typeof BroadcastChannel === "undefined") {
8
+ throw new Error("BroadcastChannel is not available in this environment.");
9
+ }
10
+ const channel = new BroadcastChannel(channelName);
11
+ const batchListeners = /* @__PURE__ */ new Set();
12
+ const presenceListeners = /* @__PURE__ */ new Set();
13
+ let lastLocalPresence = initialPresence;
14
+ const post = (msg) => {
15
+ channel.postMessage(msg);
16
+ };
17
+ channel.addEventListener("message", (e) => {
18
+ const msg = e.data;
19
+ if (msg.kind === "batch") {
20
+ if (msg.batch.clientId === clientId) return;
21
+ for (const cb of batchListeners) cb(msg.batch);
22
+ return;
23
+ }
24
+ if (msg.kind === "presence") {
25
+ if (msg.clientId === clientId) return;
26
+ for (const cb of presenceListeners) cb(msg.clientId, msg.state);
27
+ return;
28
+ }
29
+ if (msg.kind === "presence-leave") {
30
+ if (msg.clientId === clientId) return;
31
+ for (const cb of presenceListeners) cb(msg.clientId, null);
32
+ return;
33
+ }
34
+ if (msg.kind === "hello" && msg.clientId !== clientId && lastLocalPresence) {
35
+ post({ kind: "presence", clientId, state: lastLocalPresence });
36
+ }
37
+ });
38
+ post({ kind: "hello", clientId });
39
+ const onPageHide = () => {
40
+ post({ kind: "presence-leave", clientId });
41
+ };
42
+ if (typeof window !== "undefined") window.addEventListener("pagehide", onPageHide);
43
+ return {
44
+ capabilities: { causalOrdering: true },
45
+ sendBatch(batch) {
46
+ post({ kind: "batch", batch });
47
+ },
48
+ sendPresence(patch) {
49
+ const state = {
50
+ ...lastLocalPresence ?? {},
51
+ ...patch,
52
+ clientId
53
+ };
54
+ lastLocalPresence = state;
55
+ post({ kind: "presence", clientId, state });
56
+ },
57
+ onBatch(cb) {
58
+ batchListeners.add(cb);
59
+ return () => {
60
+ batchListeners.delete(cb);
61
+ };
62
+ },
63
+ onPresence(cb) {
64
+ presenceListeners.add(cb);
65
+ return () => {
66
+ presenceListeners.delete(cb);
67
+ };
68
+ },
69
+ destroy() {
70
+ if (typeof window !== "undefined") window.removeEventListener("pagehide", onPageHide);
71
+ post({ kind: "presence-leave", clientId });
72
+ channel.close();
73
+ batchListeners.clear();
74
+ presenceListeners.clear();
75
+ }
76
+ };
77
+ };
78
+
79
+ export { createBroadcastSyncAdapter };
80
+ //# sourceMappingURL=index.js.map
81
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type {\n ClientId,\n OpBatch,\n PresencePatch,\n PresenceState,\n SyncAdapter,\n Unsubscribe,\n} from '@canvas-harness/core'\n\n/**\n * BroadcastChannel-backed SyncAdapter — see ARCHITECTURE.md §10.6.\n *\n * The simplest possible adapter: serializes op batches + presence\n * patches to JSON and broadcasts over the BroadcastChannel API. Same\n * machine, multiple tabs of the same origin. Good for demos + dev.\n *\n * Causal-ordering guarantee: BroadcastChannel within a single browser\n * process delivers messages in send order, so we can advertise\n * `capabilities.causalOrdering: true`. For cross-machine / cross-origin\n * collab use a real transport (WebSocket + sequenced server, Yjs, etc.).\n */\n\ntype Wire =\n | { kind: 'batch'; batch: OpBatch }\n | { kind: 'presence'; clientId: ClientId; state: PresenceState }\n | { kind: 'presence-leave'; clientId: ClientId }\n | { kind: 'hello'; clientId: ClientId }\n\nexport type BroadcastSyncOptions = {\n /** Channel name — peers join by sharing the same name. */\n channelName: string\n /** This client's id. Used to filter self-echoes + announce on attach. */\n clientId: ClientId\n /** Optional initial presence to broadcast on attach. */\n initialPresence?: PresenceState\n}\n\nexport const createBroadcastSyncAdapter = ({\n channelName,\n clientId,\n initialPresence,\n}: BroadcastSyncOptions): SyncAdapter => {\n if (typeof BroadcastChannel === 'undefined') {\n throw new Error('BroadcastChannel is not available in this environment.')\n }\n const channel = new BroadcastChannel(channelName)\n\n const batchListeners = new Set<(batch: OpBatch) => void>()\n const presenceListeners = new Set<(clientId: ClientId, state: PresenceState | null) => void>()\n // Latest presence per remote client. We keep a snapshot so when a new\n // peer announces 'hello' we can replay our own presence to them.\n let lastLocalPresence: PresenceState | undefined = initialPresence\n\n const post = (msg: Wire): void => {\n channel.postMessage(msg)\n }\n\n channel.addEventListener('message', e => {\n const msg = e.data as Wire\n // BroadcastChannel doesn't echo to the sender, but in case a future\n // transport changes that, defensively drop self-messages.\n if (msg.kind === 'batch') {\n if (msg.batch.clientId === clientId) return\n for (const cb of batchListeners) cb(msg.batch)\n return\n }\n if (msg.kind === 'presence') {\n if (msg.clientId === clientId) return\n for (const cb of presenceListeners) cb(msg.clientId, msg.state)\n return\n }\n if (msg.kind === 'presence-leave') {\n if (msg.clientId === clientId) return\n for (const cb of presenceListeners) cb(msg.clientId, null)\n return\n }\n if (msg.kind === 'hello' && msg.clientId !== clientId && lastLocalPresence) {\n // A new peer joined; replay our presence so they can paint our cursor.\n post({ kind: 'presence', clientId, state: lastLocalPresence })\n }\n })\n\n // Announce arrival so existing peers can replay their presence to us.\n post({ kind: 'hello', clientId })\n\n // Best-effort departure: BroadcastChannel doesn't have a leave event,\n // but pagehide is close enough for a demo.\n const onPageHide = (): void => {\n post({ kind: 'presence-leave', clientId })\n }\n if (typeof window !== 'undefined') window.addEventListener('pagehide', onPageHide)\n\n return {\n capabilities: { causalOrdering: true },\n\n sendBatch(batch: OpBatch) {\n post({ kind: 'batch', batch })\n },\n\n sendPresence(patch: PresencePatch) {\n const state: PresenceState = {\n ...(lastLocalPresence ?? ({} as PresenceState)),\n ...patch,\n clientId,\n }\n lastLocalPresence = state\n post({ kind: 'presence', clientId, state })\n },\n\n onBatch(cb): Unsubscribe {\n batchListeners.add(cb)\n return () => {\n batchListeners.delete(cb)\n }\n },\n\n onPresence(cb): Unsubscribe {\n presenceListeners.add(cb)\n return () => {\n presenceListeners.delete(cb)\n }\n },\n\n destroy() {\n if (typeof window !== 'undefined') window.removeEventListener('pagehide', onPageHide)\n post({ kind: 'presence-leave', clientId })\n channel.close()\n batchListeners.clear()\n presenceListeners.clear()\n },\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@canvas-harness/sync-broadcast",
3
+ "version": "0.0.0",
4
+ "description": "BroadcastChannel-backed SyncAdapter for canvas-harness — single-machine multi-tab demos.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": ["dist", "README.md"],
17
+ "keywords": [
18
+ "canvas-harness",
19
+ "sync",
20
+ "broadcastchannel",
21
+ "collab",
22
+ "multi-tab"
23
+ ],
24
+ "author": "Le Ha Quang <winlp4ever@gmail.com>",
25
+ "license": "MIT",
26
+ "homepage": "https://github.com/winlp4ever/canvas-harness#readme",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/winlp4ever/canvas-harness.git",
30
+ "directory": "packages/sync-broadcast"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/winlp4ever/canvas-harness/issues"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "dev": "tsup --watch",
38
+ "test": "vitest run --passWithNoTests",
39
+ "typecheck": "tsc --noEmit"
40
+ },
41
+ "dependencies": {
42
+ "@canvas-harness/core": "workspace:*"
43
+ },
44
+ "devDependencies": {
45
+ "tsup": "^8.3.0",
46
+ "typescript": "^5.7.0",
47
+ "vitest": "^3.0.0"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "provenance": true
52
+ }
53
+ }