remote-state-sync 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ButterCookies
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,182 @@
1
+ # Remote State Sync
2
+
3
+ A lightweight, fully type-safe unidirectional remote state synchronization library. Effortlessly sync complex states with fine-grained precision from a Provider (e.g., Server or Electron Main) to a Receiver (e.g., Web Client or Electron Renderer).
4
+
5
+ English | [中文](./README.zh-CN.md)
6
+
7
+ ## Features
8
+
9
+ - **Unidirectional Sync**: Safely sync state from A to B with an efficient patch-based diffing system.
10
+ - **Deep Reactivity & Proxies**: Built-in, automatic support for intercepting deep changes across `Object`, `Map`, and `Set` structures.
11
+ - **Batch Patching**: Automatically queues and batches rapid state mutations into singular, optimized events for high performance.
12
+ - **Framework Agnostic Transport**: Bring your own transport layer (WebSocket, Socket.io, Electron IPC, HTTP, etc).
13
+ - **Vue Integration**: Seamlessly connects to Vue's Reactivity system out of the box (`toValue()`, `toRef()`, `toShallowRef()`).
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install remote-state-sync
19
+ ```
20
+
21
+ ## Examples
22
+
23
+ ### 1. Hono + Socket.IO (Server & Client Web)
24
+
25
+ **Server (Provider)**
26
+
27
+ ```typescript
28
+ import { SyncProvider } from 'remote-state-sync';
29
+ import { Hono } from 'hono';
30
+ import { Server } from 'socket.io';
31
+
32
+ const app = new Hono();
33
+ const io = new Server(3000);
34
+
35
+ const provider = new SyncProvider();
36
+ const usersNs = provider.register('users_space');
37
+
38
+ // Define a state
39
+ type UserState = {
40
+ connected: number;
41
+ history: string[];
42
+ };
43
+
44
+ // Initialize the state
45
+ const userState = usersNs.sync<UserState>('data', {
46
+ connected: 0,
47
+ history: [],
48
+ });
49
+
50
+ // 1. snapshotGetter implementation via HTTP endpoint
51
+ app.get('/snapshot/:namespace', (c) => {
52
+ const ns = c.req.param('namespace');
53
+ return c.json(provider.getStateSnapshot(ns));
54
+ });
55
+
56
+ io.on('connection', (socket) => {
57
+ // Mutating deeply tracks changes and queues patches automatically
58
+ userState.set((state) => void state.connected++);
59
+
60
+ socket.on('disconnect', () => {
61
+ userState.set((state) => void state.connected--);
62
+ });
63
+ });
64
+
65
+ // 2. Broadcast patches triggered by deeper modifications over WebSocket
66
+ provider.bus.on('update', (namespace, patches) => {
67
+ io.emit('state-update', namespace, patches);
68
+ });
69
+ ```
70
+
71
+ **Client (Receiver)**
72
+
73
+ ```typescript
74
+ import { SyncReceiver } from 'remote-state-sync';
75
+ import { io } from 'socket.io-client';
76
+
77
+ const socket = io('ws://localhost:3000');
78
+
79
+ const receiver = new SyncReceiver({
80
+ // 1. Fetch the initial snapshot over HTTP
81
+ snapshotGetter: async (namespace) => {
82
+ const res = await fetch(`http://localhost:3000/snapshot/${namespace}`);
83
+ return res.json();
84
+ },
85
+ });
86
+
87
+ // 2. Receive and apply patches incrementally
88
+ socket.on('state-update', (namespace, patches) => {
89
+ receiver.applyPatches(namespace, patches);
90
+ });
91
+
92
+ async function main() {
93
+ const usersNs = await receiver.register('users_space');
94
+
95
+ type UserState = { connected: number; history: string[] };
96
+ const userState = usersNs.sync<UserState>('data');
97
+
98
+ // Output: { connected: 1, history: [] }
99
+ console.log(userState.toValue());
100
+
101
+ // Listen to specific item changes!
102
+ userState.on('update', (newVal, oldVal, patches) => {
103
+ console.log('State updated!', newVal.connected);
104
+ });
105
+ }
106
+ main();
107
+ ```
108
+
109
+ ### 2. Electron (`ipcMain` + `ipcRenderer`)
110
+
111
+ **Main Process (Provider)**
112
+
113
+ ```typescript
114
+ import { SyncProvider } from 'remote-state-sync';
115
+ import { ipcMain, BrowserWindow } from 'electron';
116
+
117
+ const provider = new SyncProvider();
118
+ const appNs = provider.register('app_ns');
119
+
120
+ type SettingsState = {
121
+ theme: 'dark' | 'light';
122
+ version: string;
123
+ };
124
+
125
+ // Initialize the state
126
+ const settings = appNs.sync<SettingsState>('settings', {
127
+ theme: 'dark',
128
+ version: '1.0.0',
129
+ });
130
+
131
+ // 1. snapshotGetter via ipcMain.handle
132
+ ipcMain.handle('get-sync-snapshot', (_, namespace) => {
133
+ return provider.getStateSnapshot(namespace);
134
+ });
135
+
136
+ // 2. Broadcast patches to all renderer windows
137
+ provider.bus.on('update', (namespace, patches) => {
138
+ BrowserWindow.getAllWindows().forEach((win) => {
139
+ win.webContents.send('sync-update', namespace, patches);
140
+ });
141
+ });
142
+
143
+ // Example modification later down the line
144
+ setTimeout(() => {
145
+ settings.set((state) => void (state.theme = 'light'));
146
+ }, 5000);
147
+ ```
148
+
149
+ **Renderer Process (Receiver with Vue)**
150
+
151
+ ```typescript
152
+ import { SyncReceiver } from 'remote-state-sync';
153
+ import { ipcRenderer } from 'electron';
154
+ import { watch } from 'vue';
155
+
156
+ const receiver = new SyncReceiver({
157
+ // 1. Fetch the snapshot via ipcRenderer.invoke
158
+ snapshotGetter: (namespace) => ipcRenderer.invoke('get-sync-snapshot', namespace),
159
+ });
160
+
161
+ // 2. Listen for patches from Main process
162
+ ipcRenderer.on('sync-update', (_, namespace, patches) => {
163
+ receiver.applyPatches(namespace, patches);
164
+ });
165
+
166
+ async function setup() {
167
+ const appNs = await receiver.register('app_ns');
168
+ const settings = appNs.sync<SettingsState>('settings');
169
+
170
+ // Vue Reactivity directly tied to the remote state!
171
+ const settingsRef = settings.toRef();
172
+
173
+ watch(
174
+ settingsRef,
175
+ (newSettings) => {
176
+ console.log('Renderer theme changed to:', newSettings.theme);
177
+ },
178
+ { deep: true },
179
+ );
180
+ }
181
+ setup();
182
+ ```
@@ -0,0 +1,179 @@
1
+ # Remote State Sync
2
+
3
+ 轻量级、完全类型安全的单向远程状态同步库。轻松将复杂状态以细小粒度从 Provider(如:服务端 Server 或 Electron 主进程)同步到 Receiver(如:Web 客户端 Client 或 Electron 渲染进程)。
4
+
5
+ [English](./README.md) | 中文
6
+
7
+ ## 特性
8
+
9
+ - **单向同步**:通过高效的增量 Patch (补丁) 算法,将状态从 A 端安全地同步到 B 端。
10
+ - **深度响应与代理拦截**:内建支持对 `Object`、`Map` 和 `Set` 数据类型结构进行深层侦听、拦截与同步同步。
11
+ - **补丁按需批处理**:高频次、连续的状态变更会在底层自动批量打包合并为单次事件分发,最大化性能。
12
+ - **多端传输解耦**:不强绑定任何传输协议。可以灵活适配 WebSocket、Socket.io、Electron IPC、HTTP 等任意传输层网络!
13
+ - **完美兼容 Vue**:提供无缝的 `@vue/reactivity` 支持能力,开箱即用(支持 `.toValue()`、`.toRef()` 和 `.toShallowRef()`)。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ npm install remote-state-sync
19
+ ```
20
+
21
+ ## 场景示例
22
+
23
+ ### 1. 组合:Hono + Socket.IO (Server端与Web端通信)
24
+
25
+ **服务端 Server (提供方 Provider)**
26
+
27
+ ```typescript
28
+ import { SyncProvider } from 'remote-state-sync';
29
+ import { Hono } from 'hono';
30
+ import { Server } from 'socket.io';
31
+
32
+ const app = new Hono();
33
+ const io = new Server(3000);
34
+
35
+ const provider = new SyncProvider();
36
+ const usersNs = provider.register('users_space');
37
+
38
+ // 定义一个状态
39
+ type UserState = {
40
+ connected: number;
41
+ history: string[];
42
+ };
43
+
44
+ // 初始化这个状态
45
+ const userState = usersNs.sync<UserState>('data', {
46
+ connected: 0,
47
+ history: [],
48
+ });
49
+
50
+ // 第一步:利用 Hono 提供 HTTP 接口
51
+ // 向客户端暴露 Snapshot
52
+ app.get('/snapshot/:namespace', (c) => {
53
+ const ns = c.req.param('namespace');
54
+ return c.json(provider.getStateSnapshot(ns));
55
+ });
56
+
57
+ io.on('connection', (socket) => {
58
+ // 修改复杂对象会自动生成补丁(得益于内部 Proxy)
59
+ userState.set((state) => void state.connected++);
60
+
61
+ socket.on('disconnect', () => {
62
+ userState.set((state) => void state.connected--);
63
+ });
64
+ });
65
+
66
+ // 第二步:通过 WebSocket 向外部广播由于变更生成的批量 Patches
67
+ provider.bus.on('update', (namespace, patches) => {
68
+ io.emit('state-update', namespace, patches);
69
+ });
70
+ ```
71
+
72
+ **Web客户端 Client (接收方 Receiver)**
73
+
74
+ ```typescript
75
+ import { SyncReceiver } from 'remote-state-sync';
76
+ import { io } from 'socket.io-client';
77
+
78
+ const socket = io('ws://localhost:3000');
79
+
80
+ // 第一步:通过 HTTP 拉取远端快照,作为初始化状态
81
+ snapshotGetter: (async (namespace) => {
82
+ const res = await fetch(`http://localhost:3000/snapshot/${namespace}`);
83
+ return res.json();
84
+ },
85
+ // 第二步:接管增量 Patches,增量更新本地状态树
86
+ socket.on('state-update', (namespace, patches) => {
87
+ receiver.applyPatches(namespace, patches);
88
+ }));
89
+
90
+ async function main() {
91
+ const usersNs = await receiver.register('users_space');
92
+
93
+ type UserState = { connected: number; history: string[] };
94
+ const userState = usersNs.sync<UserState>('data');
95
+
96
+ // Output: { connected: 1, history: [] }
97
+ console.log(userState.toValue());
98
+
99
+ userState.on('update', (newVal, oldVal, patches) => {
100
+ console.log('状态更新', newVal.connected);
101
+ });
102
+ }
103
+ main();
104
+ ```
105
+
106
+ ### 2.组合:Electron (`ipcMain` + `ipcRenderer`)
107
+
108
+ **主进程 Main Process (提供方 Provider)**
109
+
110
+ ```typescript
111
+ import { SyncProvider } from 'remote-state-sync';
112
+ import { ipcMain, BrowserWindow } from 'electron';
113
+
114
+ const provider = new SyncProvider();
115
+ const appNs = provider.register('app_ns');
116
+
117
+ type SettingsState = {
118
+ theme: 'dark' | 'light';
119
+ version: string;
120
+ };
121
+
122
+ // 初始化状态
123
+ const settings = appNs.sync<SettingsState>('settings', {
124
+ theme: 'dark',
125
+ version: '1.0.0',
126
+ });
127
+
128
+ // 第一步:通过 ipcMain.handle 暴露 snapshot 获取能力
129
+ ipcMain.handle('get-sync-snapshot', (_, namespace) => {
130
+ return provider.getStateSnapshot(namespace);
131
+ });
132
+
133
+ // 第二步:将状态补丁通过 ipcEvent 主动投递给所有渲染进程
134
+ provider.bus.on('update', (namespace, patches) => {
135
+ BrowserWindow.getAllWindows().forEach((win) => {
136
+ win.webContents.send('sync-update', namespace, patches);
137
+ });
138
+ });
139
+
140
+ // 修改示例
141
+ setTimeout(() => {
142
+ settings.set((state) => void (state.theme = 'light'));
143
+ }, 5000);
144
+ ```
145
+
146
+ **渲染进程 Renderer Process (结合Vue使用的 Receiver)**
147
+
148
+ ```typescript
149
+ import { SyncReceiver } from 'remote-state-sync';
150
+ import { ipcRenderer } from 'electron';
151
+ import { watch } from 'vue';
152
+
153
+ const receiver = new SyncReceiver({
154
+ // 第一步:利用 ipcRenderer.invoke 异步请求 Snapshot
155
+ snapshotGetter: (namespace) => ipcRenderer.invoke('get-sync-snapshot', namespace),
156
+ });
157
+
158
+ // 第二步:监听来自主进程投递的 Patches
159
+ ipcRenderer.on('sync-update', (_, namespace, patches) => {
160
+ receiver.applyPatches(namespace, patches);
161
+ });
162
+
163
+ async function setup() {
164
+ const appNs = await receiver.register('app_ns');
165
+ const settings = appNs.sync<SettingsState>('settings');
166
+
167
+ // 第三步:将远端状态一键接入 Vue 的响应式生态内!
168
+ const settingsRef = settings.toRef(); // 或 toShallowRef()
169
+
170
+ watch(
171
+ settingsRef,
172
+ (newSettings) => {
173
+ console.log('Renderer theme changed to:', newSettings.theme);
174
+ },
175
+ { deep: true },
176
+ );
177
+ }
178
+ setup();
179
+ ```
@@ -0,0 +1,82 @@
1
+ import Nanobus from 'nanobus';
2
+ import { Ref, ShallowRef } from '@vue/reactivity';
3
+
4
+ type PatchOperation = 'set' | 'delete' | 'clear' | 'add';
5
+ interface Patch {
6
+ op: PatchOperation;
7
+ path: (string | number)[];
8
+ value?: unknown;
9
+ }
10
+ interface SyncOptions {
11
+ snapshotGetter: (namespace: string) => Promise<Record<string, unknown>>;
12
+ }
13
+ type SyncUpdater<T> = (state: T) => T | void;
14
+ type SyncBusDefinition = {
15
+ update: (namespace: string, patches: Patch[]) => void;
16
+ };
17
+ type ReceiverItemBusDefinition<T> = {
18
+ update: (newValue: T, oldValue: T, patches: Patch[]) => void;
19
+ };
20
+
21
+ declare class SyncProvider {
22
+ private namespaces;
23
+ bus: Nanobus<SyncBusDefinition>;
24
+ constructor();
25
+ register(namespace: string): SyncNamespaceProvider;
26
+ getStateSnapshot(namespace: string): Promise<Record<string, unknown>>;
27
+ }
28
+ declare class SyncNamespaceProvider {
29
+ readonly namespace: string;
30
+ private bus;
31
+ private items;
32
+ private queuedPatches;
33
+ private emitTimeout;
34
+ constructor(namespace: string, bus: Nanobus<SyncBusDefinition>);
35
+ sync<T>(key: string, initialValue?: T): SyncItemProvider<T>;
36
+ getSnapshot(): Record<string, unknown>;
37
+ private queuePatch;
38
+ private emitPatches;
39
+ }
40
+ declare class SyncItemProvider<T> {
41
+ readonly key: string;
42
+ private onPatch;
43
+ private value;
44
+ constructor(key: string, initialValue: T | undefined, onPatch: (patch: Patch) => void);
45
+ set(valOrUpdater: T | SyncUpdater<T>): void;
46
+ toValue(): T;
47
+ private setValue;
48
+ }
49
+
50
+ declare class SyncReceiver {
51
+ private options;
52
+ private namespaces;
53
+ constructor(options: SyncOptions);
54
+ register(namespace: string): Promise<SyncNamespaceReceiver>;
55
+ applyPatches(namespace: string, patches: Patch[]): void;
56
+ }
57
+ declare class SyncNamespaceReceiver {
58
+ readonly namespace: string;
59
+ private snapshot;
60
+ private items;
61
+ constructor(namespace: string, snapshot: Record<string, unknown>);
62
+ sync<T>(key: string): SyncItemReceiver<T>;
63
+ applyPatches(patches: Patch[]): void;
64
+ private applyPatchToObject;
65
+ }
66
+ declare class SyncItemReceiver<T> {
67
+ readonly key: string;
68
+ private value;
69
+ private _ref;
70
+ private _shallowRef;
71
+ bus: Nanobus<ReceiverItemBusDefinition<T>>;
72
+ constructor(key: string, initialValue: T);
73
+ on(event: 'update', cb: (newValue: T, oldValue: T, patches: Patch[]) => void): void;
74
+ toValue(): T;
75
+ toRef(): Ref<T>;
76
+ toShallowRef(): ShallowRef<T>;
77
+ applyPatch(patch: Patch): void;
78
+ triggerReactivity(): void;
79
+ dispose(): void;
80
+ }
81
+
82
+ export { type Patch, type PatchOperation, type ReceiverItemBusDefinition, type SyncBusDefinition, SyncItemProvider, SyncItemReceiver, SyncNamespaceProvider, SyncNamespaceReceiver, type SyncOptions, SyncProvider, SyncReceiver, type SyncUpdater };
package/dist/index.js ADDED
@@ -0,0 +1,502 @@
1
+ // src/provider.ts
2
+ import Nanobus from "nanobus";
3
+
4
+ // src/utils.ts
5
+ function isObject(val) {
6
+ return val !== null && typeof val === "object";
7
+ }
8
+ function shouldProxy(val) {
9
+ if (!isObject(val)) return false;
10
+ if (typeof val === "function") return false;
11
+ if (val instanceof Date) return false;
12
+ if (val instanceof RegExp) return false;
13
+ return true;
14
+ }
15
+ function navigatePath(obj, path, startIdx, endIdx) {
16
+ let current = obj;
17
+ for (let i = startIdx; i < endIdx; i++) {
18
+ if (current === void 0 || current === null) return current;
19
+ const step = path[i];
20
+ if (current instanceof Map) {
21
+ current = current.get(step);
22
+ } else {
23
+ current = current[step];
24
+ }
25
+ }
26
+ return current;
27
+ }
28
+ function setValueAtPath(current, key, value) {
29
+ if (!current) return;
30
+ if (current instanceof Map) {
31
+ current.set(key, value);
32
+ } else {
33
+ current[key] = value;
34
+ }
35
+ }
36
+ function deleteValueAtPath(current, key) {
37
+ if (!current) return;
38
+ if (current instanceof Map) {
39
+ current.delete(key);
40
+ } else {
41
+ delete current[key];
42
+ }
43
+ }
44
+ function addValueToSet(current, value) {
45
+ if (current instanceof Set) {
46
+ current.add(value);
47
+ }
48
+ }
49
+ function clearValue(current) {
50
+ if (current && typeof current.clear === "function") {
51
+ current.clear();
52
+ }
53
+ }
54
+
55
+ // src/proxy.ts
56
+ function createDeepProxy(target, path, onPatch) {
57
+ if (!shouldProxy(target)) {
58
+ return target;
59
+ }
60
+ if (target instanceof Map) {
61
+ return createMapProxy(target, path, onPatch);
62
+ }
63
+ if (target instanceof Set) {
64
+ return createSetProxy(target, path, onPatch);
65
+ }
66
+ return createObjectProxy(target, path, onPatch);
67
+ }
68
+ function createObjectProxy(target, path, onPatch) {
69
+ const handler = {
70
+ get(obj, prop, receiver) {
71
+ const value = Reflect.get(obj, prop, receiver);
72
+ if (typeof prop === "symbol") {
73
+ return value;
74
+ }
75
+ if (shouldProxy(value)) {
76
+ return createDeepProxy(value, [...path, prop], onPatch);
77
+ }
78
+ return value;
79
+ },
80
+ set(obj, prop, value, receiver) {
81
+ if (typeof prop === "symbol") {
82
+ return Reflect.set(obj, prop, value, receiver);
83
+ }
84
+ const success = Reflect.set(obj, prop, value, receiver);
85
+ if (success) {
86
+ onPatch({
87
+ op: "set",
88
+ path: [...path, prop],
89
+ value
90
+ });
91
+ }
92
+ return success;
93
+ },
94
+ deleteProperty(obj, prop) {
95
+ if (typeof prop === "symbol") {
96
+ return Reflect.deleteProperty(obj, prop);
97
+ }
98
+ const success = Reflect.deleteProperty(obj, prop);
99
+ if (success) {
100
+ onPatch({
101
+ op: "delete",
102
+ path: [...path, prop]
103
+ });
104
+ }
105
+ return success;
106
+ }
107
+ };
108
+ return new Proxy(target, handler);
109
+ }
110
+ function createMapProxy(target, path, onPatch) {
111
+ const handler = {
112
+ get(obj, prop) {
113
+ const value = Reflect.get(obj, prop);
114
+ if (typeof prop === "symbol") {
115
+ return typeof value === "function" ? value.bind(obj) : value;
116
+ }
117
+ if (typeof value === "function") {
118
+ if (prop === "set") {
119
+ return function(key, val) {
120
+ const result = obj.set(key, val);
121
+ onPatch({
122
+ op: "set",
123
+ path: [...path, key],
124
+ value: val
125
+ });
126
+ return result;
127
+ };
128
+ }
129
+ if (prop === "delete") {
130
+ return function(key) {
131
+ const hasKey = obj.has(key);
132
+ const result = obj.delete(key);
133
+ if (hasKey) {
134
+ onPatch({
135
+ op: "delete",
136
+ path: [...path, key]
137
+ });
138
+ }
139
+ return result;
140
+ };
141
+ }
142
+ if (prop === "clear") {
143
+ return function() {
144
+ if (obj.size > 0) {
145
+ const result = obj.clear();
146
+ onPatch({
147
+ op: "clear",
148
+ path
149
+ });
150
+ return result;
151
+ }
152
+ };
153
+ }
154
+ if (prop === "get") {
155
+ return function(key) {
156
+ const getVal = obj.get(key);
157
+ if (shouldProxy(getVal)) {
158
+ return createDeepProxy(getVal, [...path, key], onPatch);
159
+ }
160
+ return getVal;
161
+ };
162
+ }
163
+ return value.bind(obj);
164
+ }
165
+ return value;
166
+ }
167
+ };
168
+ return new Proxy(target, handler);
169
+ }
170
+ function createSetProxy(target, path, onPatch) {
171
+ const handler = {
172
+ get(obj, prop) {
173
+ const value = Reflect.get(obj, prop);
174
+ if (typeof prop === "symbol") {
175
+ return typeof value === "function" ? value.bind(obj) : value;
176
+ }
177
+ if (typeof value === "function") {
178
+ if (prop === "add") {
179
+ return function(val) {
180
+ const hasVal = obj.has(val);
181
+ const result = obj.add(val);
182
+ if (!hasVal) {
183
+ onPatch({
184
+ op: "add",
185
+ // For set, we don't have a key to navigate, but we can pass the value
186
+ // In a true Sync scenario, Set diffing is tricky. Path is just the Set itself.
187
+ path,
188
+ value: val
189
+ });
190
+ }
191
+ return result;
192
+ };
193
+ }
194
+ if (prop === "delete") {
195
+ return function(val) {
196
+ const hasVal = obj.has(val);
197
+ const result = obj.delete(val);
198
+ if (hasVal) {
199
+ onPatch({
200
+ op: "delete",
201
+ path,
202
+ // Similarly, deleting from set happens at Set boundary
203
+ value: val
204
+ // use value to identify what to delete
205
+ });
206
+ }
207
+ return result;
208
+ };
209
+ }
210
+ if (prop === "clear") {
211
+ return function() {
212
+ if (obj.size > 0) {
213
+ const result = obj.clear();
214
+ onPatch({
215
+ op: "clear",
216
+ path
217
+ });
218
+ return result;
219
+ }
220
+ };
221
+ }
222
+ return value.bind(obj);
223
+ }
224
+ return value;
225
+ }
226
+ };
227
+ return new Proxy(target, handler);
228
+ }
229
+
230
+ // src/provider.ts
231
+ var SyncProvider = class {
232
+ namespaces = /* @__PURE__ */ new Map();
233
+ bus = new Nanobus("SyncProvider");
234
+ constructor() {
235
+ }
236
+ register(namespace) {
237
+ if (this.namespaces.has(namespace)) {
238
+ return this.namespaces.get(namespace);
239
+ }
240
+ const ns = new SyncNamespaceProvider(namespace, this.bus);
241
+ this.namespaces.set(namespace, ns);
242
+ return ns;
243
+ }
244
+ async getStateSnapshot(namespace) {
245
+ const ns = this.namespaces.get(namespace);
246
+ if (!ns) {
247
+ throw new Error(`Namespace ${namespace} not found`);
248
+ }
249
+ return ns.getSnapshot();
250
+ }
251
+ };
252
+ var SyncNamespaceProvider = class {
253
+ constructor(namespace, bus) {
254
+ this.namespace = namespace;
255
+ this.bus = bus;
256
+ }
257
+ items = /* @__PURE__ */ new Map();
258
+ queuedPatches = [];
259
+ emitTimeout = null;
260
+ sync(key, initialValue) {
261
+ if (this.items.has(key)) {
262
+ throw new Error(`Item ${key} already registered in namespace ${this.namespace}`);
263
+ }
264
+ const item = new SyncItemProvider(key, initialValue, (patch) => {
265
+ this.queuePatch(patch);
266
+ });
267
+ this.items.set(key, item);
268
+ return item;
269
+ }
270
+ getSnapshot() {
271
+ const snapshot = {};
272
+ for (const [key, item] of this.items.entries()) {
273
+ snapshot[key] = item.toValue();
274
+ }
275
+ return snapshot;
276
+ }
277
+ queuePatch(patch) {
278
+ this.queuedPatches.push(patch);
279
+ if (!this.emitTimeout) {
280
+ this.emitTimeout = setTimeout(() => {
281
+ this.emitPatches();
282
+ }, 0);
283
+ }
284
+ }
285
+ emitPatches() {
286
+ if (this.queuedPatches.length === 0) return;
287
+ const patches = [...this.queuedPatches];
288
+ this.queuedPatches = [];
289
+ this.emitTimeout = null;
290
+ this.bus.emit("update", this.namespace, patches);
291
+ }
292
+ };
293
+ var SyncItemProvider = class {
294
+ constructor(key, initialValue, onPatch) {
295
+ this.key = key;
296
+ this.onPatch = onPatch;
297
+ if (initialValue !== void 0) {
298
+ this.setValue(initialValue);
299
+ }
300
+ }
301
+ value;
302
+ set(valOrUpdater) {
303
+ if (typeof valOrUpdater === "function") {
304
+ const updater = valOrUpdater;
305
+ const returnVal = updater(this.value);
306
+ if (returnVal !== void 0) {
307
+ this.setValue(returnVal);
308
+ }
309
+ } else {
310
+ this.setValue(valOrUpdater);
311
+ }
312
+ }
313
+ toValue() {
314
+ return this.value;
315
+ }
316
+ setValue(newVal) {
317
+ this.onPatch({
318
+ op: "set",
319
+ path: [this.key],
320
+ value: newVal
321
+ });
322
+ this.value = createDeepProxy(newVal, [this.key], this.onPatch);
323
+ }
324
+ };
325
+
326
+ // src/receiver.ts
327
+ import Nanobus2 from "nanobus";
328
+ import { shallowRef, ref, triggerRef } from "@vue/reactivity";
329
+ var SyncReceiver = class {
330
+ constructor(options) {
331
+ this.options = options;
332
+ }
333
+ namespaces = /* @__PURE__ */ new Map();
334
+ async register(namespace) {
335
+ if (this.namespaces.has(namespace)) {
336
+ return this.namespaces.get(namespace);
337
+ }
338
+ const snapshot = await this.options.snapshotGetter(namespace);
339
+ const ns = new SyncNamespaceReceiver(namespace, snapshot);
340
+ this.namespaces.set(namespace, ns);
341
+ return ns;
342
+ }
343
+ applyPatches(namespace, patches) {
344
+ const ns = this.namespaces.get(namespace);
345
+ if (ns) {
346
+ ns.applyPatches(patches);
347
+ }
348
+ }
349
+ };
350
+ var SyncNamespaceReceiver = class {
351
+ constructor(namespace, snapshot) {
352
+ this.namespace = namespace;
353
+ this.snapshot = snapshot;
354
+ }
355
+ items = /* @__PURE__ */ new Map();
356
+ sync(key) {
357
+ if (this.items.has(key)) {
358
+ return this.items.get(key);
359
+ }
360
+ const val = this.snapshot[key];
361
+ const item = new SyncItemReceiver(key, val);
362
+ this.items.set(key, item);
363
+ return item;
364
+ }
365
+ applyPatches(patches) {
366
+ const affectedItems = /* @__PURE__ */ new Map();
367
+ for (const patch of patches) {
368
+ if (patch.path.length === 0) continue;
369
+ const key = patch.path[0];
370
+ const item = this.items.get(key);
371
+ if (item) {
372
+ if (!affectedItems.has(item)) {
373
+ affectedItems.set(item, { oldVal: item.toValue(), patches: [] });
374
+ }
375
+ item.applyPatch(patch);
376
+ affectedItems.get(item).patches.push(patch);
377
+ } else {
378
+ this.applyPatchToObject(this.snapshot, patch);
379
+ }
380
+ }
381
+ for (const [item, data] of affectedItems.entries()) {
382
+ item.triggerReactivity();
383
+ item.bus.emit("update", item.toValue(), data.oldVal, data.patches);
384
+ }
385
+ }
386
+ applyPatchToObject(obj, patch) {
387
+ if (patch.path.length === 0) return;
388
+ if (patch.op === "clear" || patch.op === "add") {
389
+ const target = navigatePath(obj, patch.path, 0, patch.path.length);
390
+ if (patch.op === "clear") {
391
+ clearValue(target);
392
+ } else {
393
+ addValueToSet(target, patch.value);
394
+ }
395
+ return;
396
+ }
397
+ const current = navigatePath(obj, patch.path, 0, patch.path.length - 1);
398
+ const lastKey = patch.path[patch.path.length - 1];
399
+ if (patch.op === "set") {
400
+ setValueAtPath(current, lastKey, patch.value);
401
+ } else if (patch.op === "delete") {
402
+ deleteValueAtPath(current, lastKey);
403
+ }
404
+ }
405
+ };
406
+ var SyncItemReceiver = class {
407
+ constructor(key, initialValue) {
408
+ this.key = key;
409
+ this.value = initialValue;
410
+ }
411
+ value;
412
+ _ref = null;
413
+ _shallowRef = null;
414
+ bus = new Nanobus2("SyncItemReceiver");
415
+ on(event, cb) {
416
+ this.bus.on(event, cb);
417
+ }
418
+ toValue() {
419
+ return this.value;
420
+ }
421
+ toRef() {
422
+ if (!this._ref) {
423
+ this._ref = ref(this.value);
424
+ }
425
+ return this._ref;
426
+ }
427
+ toShallowRef() {
428
+ if (!this._shallowRef) {
429
+ this._shallowRef = shallowRef(this.value);
430
+ }
431
+ return this._shallowRef;
432
+ }
433
+ applyPatch(patch) {
434
+ if (patch.path.length === 1) {
435
+ if (patch.op === "set") {
436
+ this.value = patch.value;
437
+ if (this._ref) this._ref.value = patch.value;
438
+ if (this._shallowRef) this._shallowRef.value = patch.value;
439
+ }
440
+ return;
441
+ }
442
+ const lastKey = patch.path[patch.path.length - 1];
443
+ if (patch.op === "set") {
444
+ const current = navigatePath(this.value, patch.path, 1, patch.path.length - 1);
445
+ const refCurrent = navigatePath(
446
+ this._ref ? this._ref.value : null,
447
+ patch.path,
448
+ 1,
449
+ patch.path.length - 1
450
+ );
451
+ setValueAtPath(current, lastKey, patch.value);
452
+ setValueAtPath(refCurrent, lastKey, patch.value);
453
+ } else if (patch.op === "delete") {
454
+ const current = navigatePath(this.value, patch.path, 1, patch.path.length - 1);
455
+ const refCurrent = navigatePath(
456
+ this._ref ? this._ref.value : null,
457
+ patch.path,
458
+ 1,
459
+ patch.path.length - 1
460
+ );
461
+ deleteValueAtPath(current, lastKey);
462
+ deleteValueAtPath(refCurrent, lastKey);
463
+ } else if (patch.op === "add") {
464
+ const target = navigatePath(this.value, patch.path, 1, patch.path.length);
465
+ const refTarget = navigatePath(
466
+ this._ref ? this._ref.value : null,
467
+ patch.path,
468
+ 1,
469
+ patch.path.length
470
+ );
471
+ addValueToSet(target, patch.value);
472
+ addValueToSet(refTarget, patch.value);
473
+ } else if (patch.op === "clear") {
474
+ const target = navigatePath(this.value, patch.path, 1, patch.path.length);
475
+ const refTarget = navigatePath(
476
+ this._ref ? this._ref.value : null,
477
+ patch.path,
478
+ 1,
479
+ patch.path.length
480
+ );
481
+ clearValue(target);
482
+ clearValue(refTarget);
483
+ }
484
+ }
485
+ triggerReactivity() {
486
+ if (this._shallowRef) {
487
+ triggerRef(this._shallowRef);
488
+ }
489
+ }
490
+ dispose() {
491
+ this._ref = null;
492
+ this._shallowRef = null;
493
+ }
494
+ };
495
+ export {
496
+ SyncItemProvider,
497
+ SyncItemReceiver,
498
+ SyncNamespaceProvider,
499
+ SyncNamespaceReceiver,
500
+ SyncProvider,
501
+ SyncReceiver
502
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "remote-state-sync",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight, fully type-safe unidirectional remote state synchronization library.",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+ssh://git@github.com/Coooookies/remote-state-sync.git"
9
+ },
10
+ "license": "MIT",
11
+ "author": {
12
+ "name": "Coooookies",
13
+ "email": "admin@mitay.net"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "module": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "import": "./dist/index.js",
21
+ "types": "./dist/index.d.ts"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "publishConfig": {
31
+ "registry": "https://registry.npmjs.org/",
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "dev": "tsup src/index.ts --watch",
36
+ "build": "tsup src/index.ts --format esm --dts --clean",
37
+ "prepublishOnly": "npm run lint && npm run test && npm run build",
38
+ "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
39
+ "typecheck": "tsc --noEmit",
40
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest",
43
+ "test:coverage": "vitest run --coverage"
44
+ },
45
+ "devDependencies": {
46
+ "@eslint/js": "^10.0.1",
47
+ "@vitest/coverage-v8": "^4.0.18",
48
+ "eslint": "^10.0.0",
49
+ "eslint-config-prettier": "^10.1.8",
50
+ "prettier": "^3.8.1",
51
+ "tsup": "^8.5.1",
52
+ "typescript": "^5.9.3",
53
+ "typescript-eslint": "^8.56.0",
54
+ "vitest": "^4.0.18"
55
+ },
56
+ "dependencies": {
57
+ "@vue/reactivity": "^3.5.28",
58
+ "nanobus": "^4.5.0"
59
+ }
60
+ }