├── .github └── workflows │ └── npm-publish.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── dist ├── pushmodel.d.ts ├── pushmodel.js ├── util.d.ts └── util.js ├── examples ├── chat │ ├── chat.html │ ├── model.js │ └── model.test.js ├── messenger │ ├── active.png │ ├── components.css │ ├── components.html │ ├── components.js │ ├── inactive.png │ ├── messenger.html │ ├── model.js │ ├── model.test.js │ └── resize.png └── sharedtodolist │ ├── components.html │ ├── model.js │ ├── model.test.js │ └── todo.html ├── package-lock.json ├── package.json ├── src ├── pushmodel.ts └── util.ts └── tsconfig.json /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: 12 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v2 27 | with: 28 | node-version: 12 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | cache: 5 | directories: 6 | - "node_modules" 7 | script: 8 | - "npm run build" 9 | - "npm test" 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Hai Phan 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. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://travis-ci.org/ken107/push-model) 2 | 3 | ## What's This? 4 | This Node module implements a WebSocket JSON-RPC server with object synchronization capabilities based on the JSON-Pointer and JSON-Patch standards. 5 | 6 | It is called a Push Model because it is intended to be used as part of a server-side MVC Model layer or MVVM ViewModel layer that requires the ability to push changes to clients. 7 | 8 | The Model/ViewModel layer can handle JSON-RPC requests and return data directly to the requester, or it may choose to place data in a _model object_ that is published to interested clients. [Harmony Proxy](https://github.com/ken107/jsonpatch-observe) is used to detect subsequent changes to the data, which are published incrementally as JSON Patches. 9 | 10 | 11 | ## How To Use 12 | ```javascript 13 | const pm = require("push-model"); 14 | const server = require("http").createServer(); 15 | const model = { 16 | //properties that client can subscribe to 17 | prop1: ..., 18 | prop2: ..., 19 | 20 | //RPC methods 21 | method1: function(...args) { 22 | ... 23 | return result; //or promise 24 | }, 25 | method2: ... 26 | }; 27 | 28 | pm.mount(server, "/path", model, acceptOrigins); 29 | ``` 30 | This creates an HTTP server and mounts `model` on the specified route. Clients can send RPC requests to this route over either HTTP or WebSocket, which invoke the corresponding methods on the model. Return values are automatically sent back as JSON-RPC responses. 31 | 32 | 33 | ### Special Methods 34 | The PUB/SUB mechanism is only available to WebSocket clients. 35 | 36 | ##### SUB/UNSUB 37 | Clients call SUB/UNSUB to start/stop observing changes to the model object. The `pointer` parameter, a JSON Pointer, indicates which part of the model to observe. 38 | ``` 39 | SUB(pointer) 40 | UNSUB(pointer) 41 | ``` 42 | 43 | ##### PUB 44 | Server calls PUB to notify clients of changes to the model. The `patches` parameter holds an array of JSON Patches describing a series of changes that were made to the model. 45 | ``` 46 | SUB(patches) 47 | ``` 48 | 49 | 50 | ### Special Return Values 51 | 52 | ##### ErrorResponse 53 | A return value of type ErrorResponse will be translated into a JSON-RPC error message. 54 | ``` 55 | return new pm.ErrorResponse(code, message, data); 56 | ``` 57 | 58 | 59 | ### A Example Model 60 | An MVC chat server that uses object synchronization to push chat messages to clients. 61 | ```javascript 62 | pm.mount(server, "/chat", { 63 | //data 64 | chatLog: [], 65 | 66 | //actions 67 | sendChat: function(name, message) { 68 | this.chatLog.push(name + ": " + message); 69 | } 70 | }); 71 | ``` 72 | 73 | 74 | ## Examples 75 | 76 | ##### Running the Chat Example 77 | Open a command prompt in the push-model directory and run: 78 | ``` 79 | npm install 80 | node examples/chat/model.js 81 | ``` 82 | That will start the chat model on localhost:8080. Then open the file examples/chat/chat.html in two browser windows and start chatting! 83 | 84 | ##### Running the Shared TodoList Example 85 | Open a command prompt in the push-model directory and run: 86 | ``` 87 | npm install 88 | node examples/sharedtodolist/model.js 89 | ``` 90 | Then open the file examples/sharedtodolist/todo.html in two or more browser windows. If you use Chrome, you must run a local web server because Chrome does not allow AJAX over file:// URL. 91 | 92 | ##### Running the Messenger Example 93 | ``` 94 | npm install 95 | node examples/messenger/model.js 96 | ``` 97 | Then open the file examples/messenger/messenger.html in two or more browser windows. Enter a user ID and name to login to the messenger app. 98 | 99 | 100 | ## Other Features 101 | View the [wiki](http://github.com/ken107/push-model/wiki) for other features supported by the Push Model. 102 | -------------------------------------------------------------------------------- /dist/pushmodel.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /** 3 | * Push Model 4 | * Copyright 2018, Hai Phan 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE file in the root directory of this source tree. 8 | */ 9 | import { Server } from "http"; 10 | export declare const options: { 11 | enableSplice: boolean; 12 | excludeProperty: (target: any, prop: string) => boolean; 13 | } & { 14 | enableSplice: boolean; 15 | excludeProperty: (target: any, prop: string) => boolean; 16 | }; 17 | export declare class ErrorResponse { 18 | code: number; 19 | message: string; 20 | data: string; 21 | constructor(code: number, message: string, data: string); 22 | } 23 | export declare function mount(server: Server, path: string, model: any, acceptOrigins: Array): void; 24 | export declare function trackKeys(obj: any): any; 25 | export declare function untrackKeys(obj: any): void; 26 | -------------------------------------------------------------------------------- /dist/pushmodel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.untrackKeys = exports.trackKeys = exports.mount = exports.ErrorResponse = exports.options = void 0; 7 | const url_1 = require("url"); 8 | const ws_1 = __importDefault(require("ws")); 9 | const json_pointer_1 = require("json-pointer"); 10 | const jsonpatch_observe_1 = require("jsonpatch-observe"); 11 | exports.options = Object.assign(jsonpatch_observe_1.config, { 12 | enableSplice: true, 13 | excludeProperty: (target, prop) => typeof prop == "string" && prop.startsWith('_') 14 | }); 15 | class ErrorResponse { 16 | constructor(code, message, data) { 17 | this.code = code; 18 | this.message = message; 19 | this.data = data; 20 | } 21 | } 22 | exports.ErrorResponse = ErrorResponse; 23 | function mount(server, path, model, acceptOrigins) { 24 | model = (0, jsonpatch_observe_1.observe)(model); 25 | server.on("request", function (req, res) { 26 | if ((0, url_1.parse)(req.url).pathname == path) { 27 | if (req.headers.origin) { 28 | const url = require("url").parse(req.headers.origin); 29 | if (acceptOrigins == null) 30 | res.setHeader("Access-Control-Allow-Origin", "*"); 31 | else if (acceptOrigins.indexOf(url.hostname) != -1) 32 | res.setHeader("Access-Control-Allow-Origin", req.headers.origin); 33 | } 34 | if (req.method == "POST") { 35 | let text = ''; 36 | req.setEncoding('utf8'); 37 | req.on('data', chunk => text += chunk); 38 | req.on('end', () => new RequestHandler(null, model, send).handle(text)); 39 | } 40 | else if (req.method == "OPTIONS" && req.headers["access-control-request-method"]) { 41 | res.setHeader("Access-Control-Allow-Methods", "POST"); 42 | res.end(); 43 | } 44 | else { 45 | res.writeHead(405, "Method Not Allowed"); 46 | res.end(); 47 | } 48 | } 49 | function send(message) { 50 | res.setHeader("Content-Type", "application/json"); 51 | res.end(serialize(message), "utf8"); 52 | } 53 | }); 54 | const wss = new ws_1.default.Server({ 55 | server: server, 56 | path: path, 57 | verifyClient: function (info, callback) { 58 | const origin = info.origin ? require("url").parse(info.origin).hostname : null; 59 | if (acceptOrigins == null || acceptOrigins.indexOf(origin) != -1) { 60 | if (callback) 61 | callback(true); 62 | else 63 | return true; 64 | } 65 | else { 66 | if (callback) 67 | callback(false, 403, "Forbidden"); 68 | else 69 | return false; 70 | } 71 | } 72 | }); 73 | wss.on("connection", function (ws) { 74 | let session = {}; 75 | const subman = new SubMan(model, send); 76 | onReceive('{"jsonrpc":"2.0","method":"onConnect"}'); 77 | ws.on("message", onReceive); 78 | ws.on("close", onClose); 79 | async function onReceive(text) { 80 | model.session = session; 81 | await new RequestHandler(subman, model, send).handle(text); 82 | session = model.session; 83 | model.session = null; 84 | } 85 | function onClose() { 86 | subman.unsubscribeAll(); 87 | onReceive('{"jsonrpc":"2.0","method":"onDisconnect"}'); 88 | } 89 | function send(message) { 90 | ws.send(serialize(message), function (err) { 91 | if (err) 92 | console.log(err.stack || err); 93 | }); 94 | } 95 | }); 96 | function serialize(message) { 97 | return JSON.stringify(message, function (key, value) { 98 | return key != '' && jsonpatch_observe_1.config.excludeProperty(this, key) ? undefined : value; 99 | }); 100 | } 101 | } 102 | exports.mount = mount; 103 | class RequestHandler { 104 | constructor(subman, model, send) { 105 | this.subman = subman; 106 | this.model = model; 107 | this.send = send; 108 | this.countResponses = 0; 109 | this.responses = []; 110 | } 111 | async handle(text) { 112 | try { 113 | const data = JSON.parse(text); 114 | const requests = Array.isArray(data) ? data : [data]; 115 | this.countResponses = requests.reduce(function (sum, request) { return request.id !== undefined ? sum + 1 : sum; }, 0); 116 | for (const request of requests) 117 | await this.handleRequest(request); 118 | } 119 | catch (err) { 120 | console.error(err); 121 | this.countResponses = 1; 122 | this.sendError(null, -32700, "Parse error"); 123 | } 124 | } 125 | async handleRequest(request) { 126 | if (request.jsonrpc != "2.0") { 127 | this.sendError(request.id, -32600, "Invalid request", "Not JSON-RPC version 2.0"); 128 | return; 129 | } 130 | let func; 131 | switch (request.method) { 132 | case "SUB": 133 | if (this.subman) 134 | func = this.subman.subscribe.bind(this.subman); 135 | break; 136 | case "UNSUB": 137 | if (this.subman) 138 | func = this.subman.unsubscribe.bind(this.subman); 139 | break; 140 | default: func = this.model[request.method]; 141 | } 142 | if (!(func instanceof Function)) { 143 | this.sendError(request.id, -32601, "Method not found"); 144 | return; 145 | } 146 | try { 147 | const result = await func.apply(this.model, request.params || []); 148 | if (result instanceof ErrorResponse) 149 | this.sendError(request.id, result.code, result.message, result.data); 150 | else 151 | this.sendResult(request.id, result); 152 | } 153 | catch (err) { 154 | console.log(err); 155 | this.sendError(request.id, -32603, "Internal error"); 156 | } 157 | } 158 | sendResult(id, result) { 159 | if (id !== undefined) 160 | this.sendResponse({ jsonrpc: "2.0", id: id, result: result }); 161 | } 162 | sendError(id, code, message, data) { 163 | if (id !== undefined) 164 | this.sendResponse({ jsonrpc: "2.0", id: id, error: { code: code, message: message, data: data } }); 165 | } 166 | sendResponse(response) { 167 | this.responses.push(response); 168 | if (this.responses.length == this.countResponses) 169 | this.send(this.responses.length == 1 ? this.responses[0] : this.responses); 170 | } 171 | } 172 | var idGen = 0; 173 | class SubMan { 174 | constructor(model, send) { 175 | this.model = model; 176 | this.send = send; 177 | this.id = idGen = (idGen || 0) + 1; 178 | this.subscriptions = {}; 179 | this.pendingPatches = []; 180 | } 181 | subscribe(pointer) { 182 | if (pointer == null) 183 | return new ErrorResponse(-32602, "Invalid params", "Missing param 'pointer'"); 184 | if (typeof pointer != "string") 185 | return new ErrorResponse(-32602, "Invalid params", "Pointer must be a string"); 186 | if (pointer == "") 187 | return new ErrorResponse(-32602, "Invalid params", "Cannot subscribe to the root model object"); 188 | if (this.subscriptions[pointer]) 189 | this.subscriptions[pointer].count++; 190 | else { 191 | if (!(0, json_pointer_1.has)(this.model, pointer)) 192 | return new ErrorResponse(0, "Application error", "Can't subscribe to '" + pointer + "', path not found"); 193 | const obj = (0, json_pointer_1.get)(this.model, pointer); 194 | if (!(obj instanceof Object)) 195 | return new ErrorResponse(0, "Application error", "Can't subscribe to '" + pointer + "', value is not an object"); 196 | this.onPatch(pointer, { op: "replace", path: "", value: obj }); 197 | this.subscriptions[pointer] = { 198 | target: obj, 199 | callback: this.onPatch.bind(this, pointer), 200 | count: 1 201 | }; 202 | obj.$subscribe(this.subscriptions[pointer].callback); 203 | } 204 | } 205 | ; 206 | unsubscribe(pointer) { 207 | if (pointer == null) 208 | return new ErrorResponse(-32602, "Invalid params", "Missing param 'pointer'"); 209 | if (typeof pointer != "string") 210 | return new ErrorResponse(-32602, "Invalid params", "Pointer must be a string"); 211 | if (this.subscriptions[pointer]) { 212 | this.subscriptions[pointer].count--; 213 | if (this.subscriptions[pointer].count <= 0) { 214 | this.subscriptions[pointer].target.$unsubscribe(this.subscriptions[pointer].callback); 215 | delete this.subscriptions[pointer]; 216 | } 217 | } 218 | } 219 | unsubscribeAll() { 220 | for (const pointer in this.subscriptions) 221 | this.subscriptions[pointer].target.$unsubscribe(this.subscriptions[pointer].callback); 222 | } 223 | onPatch(pointer, patch) { 224 | //console.log(this.id, pointer, patch); 225 | if (!this.pendingPatches.length) 226 | setTimeout(this.sendPendingPatches.bind(this), 0); 227 | this.pendingPatches.push(this.copyPatch(patch, pointer + patch.path)); 228 | } 229 | sendPendingPatches() { 230 | this.send({ jsonrpc: "2.0", method: "PUB", params: [this.pendingPatches] }); 231 | this.pendingPatches = []; 232 | } 233 | copyPatch(patch, newPath) { 234 | switch (patch.op) { 235 | case "remove": return { op: patch.op, path: newPath }; 236 | case "splice": return { op: patch.op, path: newPath, remove: patch.remove, add: patch.add }; 237 | default: return { op: patch.op, path: newPath, value: patch.value }; 238 | } 239 | } 240 | } 241 | function trackKeys(obj) { 242 | if (!obj.$handler) 243 | obj = (0, jsonpatch_observe_1.observe)(obj); 244 | if (!obj.keys) 245 | obj.keys = Object.keys(obj).filter(prop => !jsonpatch_observe_1.config.excludeProperty(obj, prop)); 246 | if (!obj._keysUpdater) 247 | obj.$subscribe(obj._keysUpdater = updateKeys.bind(null, obj)); 248 | return obj; 249 | } 250 | exports.trackKeys = trackKeys; 251 | function untrackKeys(obj) { 252 | if (obj._keysUpdater) { 253 | obj.$unsubscribe(obj._keysUpdater); 254 | delete obj._keysUpdater; 255 | } 256 | } 257 | exports.untrackKeys = untrackKeys; 258 | function updateKeys(obj, patch) { 259 | const tokens = patch.path.split("/"); 260 | if (tokens.length == 2) { 261 | const prop = tokens[1]; 262 | if (!jsonpatch_observe_1.config.excludeProperty(obj, prop)) { 263 | if (patch.op == "add") { 264 | const index = obj.keys.indexOf(prop); 265 | if (index == -1) 266 | obj.keys.push(prop); 267 | } 268 | else if (patch.op == "remove") { 269 | const index = obj.keys.indexOf(prop); 270 | if (index != -1) 271 | obj.keys.splice(index, 1); 272 | } 273 | } 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /dist/util.d.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from "ws"; 2 | export declare function beforeAll(f: Function): void; 3 | export declare function afterAll(f: Function): void; 4 | export declare function test(name: string, run: Function): Promise; 5 | export declare function expect(a: unknown): { 6 | toEqual(b: object): void; 7 | }; 8 | export declare class TestClient { 9 | waiting: Array<(result: any) => void>; 10 | incoming: Array; 11 | ws?: WebSocket; 12 | constructor(); 13 | connect(url: string): Promise; 14 | send(req: any): void; 15 | receive(): Promise; 16 | close(): void; 17 | } 18 | export declare function makeReq(id: number, method: string, params: Array): { 19 | method: string; 20 | params: any[]; 21 | id?: number | undefined; 22 | jsonrpc: string; 23 | }; 24 | export declare function makeRes(id: number, result: any): { 25 | result?: any; 26 | id?: number | undefined; 27 | jsonrpc: string; 28 | }; 29 | -------------------------------------------------------------------------------- /dist/util.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.makeRes = exports.makeReq = exports.TestClient = exports.expect = exports.test = exports.afterAll = exports.beforeAll = void 0; 7 | const util_1 = require("util"); 8 | const ws_1 = __importDefault(require("ws")); 9 | const before = []; 10 | const after = []; 11 | function beforeAll(f) { 12 | before.push(f); 13 | } 14 | exports.beforeAll = beforeAll; 15 | function afterAll(f) { 16 | after.push(f); 17 | } 18 | exports.afterAll = afterAll; 19 | async function test(name, run) { 20 | console.log("Running test", name); 21 | for (const f of before) 22 | await f(); 23 | await run(); 24 | for (const f of after) 25 | await f(); 26 | } 27 | exports.test = test; 28 | function expect(a) { 29 | return { 30 | toEqual(b) { 31 | if (!(0, util_1.isDeepStrictEqual)(a, b)) { 32 | console.log("Received", a); 33 | console.log("Expected", b); 34 | throw new Error("Assertion failed"); 35 | } 36 | } 37 | }; 38 | } 39 | exports.expect = expect; 40 | class TestClient { 41 | constructor() { 42 | this.waiting = []; 43 | this.incoming = []; 44 | } 45 | connect(url) { 46 | return new Promise(fulfill => { 47 | this.ws = new ws_1.default(url); 48 | this.ws.on("open", fulfill); 49 | this.ws.on("message", (text) => { 50 | this.incoming.push(JSON.parse(text)); 51 | while (this.incoming.length && this.waiting.length) 52 | this.waiting.shift()(this.incoming.shift()); 53 | }); 54 | }); 55 | } 56 | send(req) { 57 | this.ws.send(JSON.stringify(req)); 58 | } 59 | receive() { 60 | return new Promise(fulfill => { 61 | this.waiting.push(fulfill); 62 | while (this.incoming.length && this.waiting.length) 63 | this.waiting.shift()(this.incoming.shift()); 64 | }); 65 | } 66 | close() { 67 | if (this.ws) 68 | this.ws.close(); 69 | } 70 | } 71 | exports.TestClient = TestClient; 72 | function makeReq(id, method, params) { 73 | return { 74 | jsonrpc: "2.0", 75 | ...(id !== undefined ? { id } : null), 76 | method, 77 | params 78 | }; 79 | } 80 | exports.makeReq = makeReq; 81 | function makeRes(id, result) { 82 | return { 83 | jsonrpc: "2.0", 84 | ...(id !== undefined ? { id } : null), 85 | ...(result !== undefined ? { result } : null) 86 | }; 87 | } 88 | exports.makeRes = makeRes; 89 | -------------------------------------------------------------------------------- /examples/chat/chat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 28 | 29 | 30 | 31 | 32 | {{#entry}} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /examples/chat/model.js: -------------------------------------------------------------------------------- 1 | var http = require("http"), 2 | server = http.createServer(), 3 | pm = require("../../dist/pushmodel.js"); 4 | 5 | server.listen(8085); 6 | 7 | pm.mount(server, "/chat", { 8 | chatLog: ["Welcome!"], 9 | sendChat: function(name, message) { 10 | this.chatLog.push(name + ": " + message); 11 | } 12 | }); 13 | 14 | exports.shutdown = () => server.close(); 15 | -------------------------------------------------------------------------------- /examples/chat/model.test.js: -------------------------------------------------------------------------------- 1 | const { shutdown } = require("./model.js"); 2 | const { TestClient, makeReq, makeRes, beforeAll, afterAll, test, expect } = require("../../dist/util.js"); 3 | 4 | const c1 = new TestClient(); 5 | const c2 = new TestClient(); 6 | 7 | beforeAll(async () => { 8 | await c1.connect("ws://localhost:8085/chat"); 9 | await c2.connect("ws://localhost:8085/chat"); 10 | }) 11 | 12 | afterAll(() => { 13 | shutdown(); 14 | c1.close(); 15 | c2.close(); 16 | }) 17 | 18 | test("only one", async () => { 19 | let id = 0; 20 | let output; 21 | 22 | c1.send(makeReq(++id, "SUB", ["/chatLog"])); 23 | expect(await c1.receive()).toEqual(makeRes(id)); 24 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"replace", path:"/chatLog", value:["Welcome!"]}]])); 25 | 26 | c2.send(makeReq(++id, "SUB", ["/chatLog"])); 27 | expect(await c2.receive()).toEqual(makeRes(id)); 28 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"replace", path:"/chatLog", value:["Welcome!"]}]])); 29 | 30 | c2.send(makeReq(++id, "sendChat", ["John", "Hey, what's up?"])); 31 | expect(await c2.receive()).toEqual(makeRes(id)); 32 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"splice", path:"/chatLog/1", remove:0, add:["John: Hey, what's up?"]}]])); 33 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"splice", path:"/chatLog/1", remove:0, add:["John: Hey, what's up?"]}]])); 34 | }) 35 | -------------------------------------------------------------------------------- /examples/messenger/active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ken107/push-model/4ebae68fe25c3222bc85d3a329064ab20bd6a41b/examples/messenger/active.png -------------------------------------------------------------------------------- /examples/messenger/components.css: -------------------------------------------------------------------------------- 1 | .messenger { 2 | font-family: Arial; 3 | font-size: 12px; 4 | } 5 | .messenger .link { 6 | cursor: pointer; 7 | } 8 | .messenger .link:hover { 9 | text-decoration: underline; 10 | } 11 | .messenger .title-bar { 12 | background: #a3a3a3; 13 | color: white; 14 | padding: 5px; 15 | } 16 | .messenger .close-button { 17 | background: #555; 18 | color: white; 19 | padding: 0 4px 1px 4px; 20 | margin-left: 10px; 21 | float: right; 22 | cursor: pointer; 23 | border-radius: 10px; 24 | } 25 | .messenger .resize-icon { 26 | cursor: nwse-resize; 27 | } 28 | .messenger .toggle-button { 29 | padding: 2px 3px 0 3px; 30 | border-radius: 5px; 31 | border: 1px outset lightgray; 32 | display: inline-block; 33 | background: #fafafa; 34 | cursor: pointer; 35 | } 36 | .messenger .toggle-button.depressed { 37 | border: 1px inset lightgray; 38 | } 39 | 40 | .messenger .user-list { 41 | position: fixed; 42 | top: 50px; 43 | right: 0; 44 | border: 1px outset lightgray; 45 | border-radius: 5px; 46 | background: white; 47 | } 48 | .messenger .user-list .item { 49 | padding: 5px 25px 5px 5px; 50 | cursor: pointer; 51 | } 52 | .messenger .user-list .item:hover { 53 | background: #eee; 54 | } 55 | .messenger .user-list .buttons { 56 | padding: 5px; 57 | background: lightgray; 58 | } 59 | 60 | .messenger .chat-box { 61 | position: fixed; 62 | left: 100px; 63 | top: 100px; 64 | width: 250px; 65 | border: 1px outset lightgray; 66 | border-radius: 5px; 67 | } 68 | .messenger .chat-box .title-bar { 69 | cursor: move; 70 | } 71 | .messenger .chat-log { 72 | height: 250px; 73 | overflow: auto; 74 | padding: 5px; 75 | color: #333; 76 | background: white; 77 | } 78 | .messenger .chat-log .time-marker { 79 | color: gray; 80 | text-align: center; 81 | margin: 10px 0; 82 | } 83 | .messenger .chat-entry { 84 | padding: 5px 10px 5px 10px; 85 | margin-bottom: 5px; 86 | border: 1px solid #ddd; 87 | border-radius: 5px; 88 | background: #ffd; 89 | } 90 | .messenger .chat-entry.me { 91 | background: #f5f5f5; 92 | } 93 | .messenger .chat-form { 94 | background: lightgray; 95 | padding: 4px; 96 | } 97 | .messenger .chat-form input[name=message] { 98 | box-sizing: border-box; 99 | width: 100%; 100 | } 101 | -------------------------------------------------------------------------------- /examples/messenger/components.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | Messenger 9 | 14 | 25 | 26 | 27 | 28 | 31 | 32 | x 34 | Messenger 35 | 36 | 41 | 42 | {{#user.name}} 43 | 44 | 45 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | x 68 | 69 | {{#otherUser.name}} 70 | 71 | 75 | 77 | {{#message.time && this.formatTime(#message.time)}} 79 | {{#message.text}} 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /examples/messenger/components.js: -------------------------------------------------------------------------------- 1 | 2 | function Messenger(elem) { 3 | this.users = null; 4 | this.session = null; 5 | var ws; 6 | var idGen = 0; 7 | this.connect = function() { 8 | if (ws) ws.close(); 9 | ws = new (WebSocket || MozWebSocket)(this.connectUrl); 10 | ws.onopen = (function() { 11 | ws.send(JSON.stringify([ 12 | {jsonrpc: "2.0", id: ++idGen, method: "signIn", params: [this.myUserInfo]}, 13 | {jsonrpc: "2.0", id: ++idGen, method: "SUB", params: ["/users"]}, 14 | {jsonrpc: "2.0", id: ++idGen, method: "SUB", params: ["/session"]} 15 | ])); 16 | }).bind(this); 17 | ws.onmessage = (function(e) { 18 | console.log('<', e.data); 19 | var m = JSON.parse(e.data); 20 | if (m.method == "PUB") jsonpatch.applyPatch(this, m.params[0]); 21 | }).bind(this); 22 | }; 23 | this.action = function(method, args) { 24 | var text = JSON.stringify({jsonrpc: "2.0", id: ++idGen, method: method, params: args}); 25 | console.log('>', text); 26 | ws.send(text); 27 | }; 28 | } 29 | 30 | function UserList(elem) { 31 | } 32 | 33 | function ChatBox(elem) { 34 | this.formatTime = function(time) { 35 | var when = new Date(time); 36 | var result = formatAMPM(when); 37 | var now = new Date(); 38 | if (now.getFullYear() != when.getFullYear() || now.getMonth() != when.getMonth() || now.getDate() != when.getDate()) 39 | result = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][when.getMonth()] + " " + when.getDate() + ", " + when.getFullYear() + " " + result; 40 | return result; 41 | } 42 | function formatAMPM(date) { 43 | var hours = date.getHours(); 44 | var minutes = date.getMinutes(); 45 | var ampm = hours >= 12 ? 'pm' : 'am'; 46 | hours = hours % 12; 47 | hours = hours ? hours : 12; 48 | minutes = minutes < 10 ? '0'+minutes : minutes; 49 | var strTime = hours + ':' + minutes + ' ' + ampm; 50 | return strTime; 51 | } 52 | } 53 | 54 | function Dragger(elem, onComplete) { 55 | this.start = function(event) { 56 | this.position = $(elem).position(); 57 | this.origin = {x: event.clientX, y: event.clientY}; 58 | $(document).on("mousemove", this.move).on("mouseup", this.stop); 59 | return false; 60 | }; 61 | this.move = (function(event) { 62 | $(elem).css({ 63 | top: this.position.top + (event.clientY - this.origin.y), 64 | left: this.position.left + (event.clientX - this.origin.x) 65 | }); 66 | return false; 67 | }).bind(this); 68 | this.stop = (function(event) { 69 | $(document).off("mousemove", this.move).off("mouseup", this.stop); 70 | if (onComplete) onComplete($(elem).position()); 71 | return false; 72 | }).bind(this); 73 | } 74 | 75 | function Resizer(widthOf, heightOf, onComplete) { 76 | this.widthOf = widthOf; 77 | this.heightOf = heightOf; 78 | this.start = function(event) { 79 | this.size = {width: $(this.widthOf).width(), height: $(this.heightOf).height()}; 80 | this.origin = {x: event.clientX, y: event.clientY}; 81 | $(document).on("mousemove", this.move).on("mouseup", this.stop); 82 | return false; 83 | }; 84 | this.move = (function(event) { 85 | $(this.widthOf).width(this.size.width + (event.clientX - this.origin.x)); 86 | $(this.heightOf).height(this.size.height + (event.clientY - this.origin.y)); 87 | return false; 88 | }).bind(this); 89 | this.stop = (function(event) { 90 | $(document).off("mousemove", this.move).off("mouseup", this.stop); 91 | if (onComplete) onComplete({width: $(this.widthOf).width(), height: $(this.heightOf).height()}); 92 | return false; 93 | }).bind(this); 94 | } 95 | 96 | function Notifier() { 97 | var timer; 98 | this.show = function(text) { 99 | if (!document.hasFocus() && !timer) { 100 | var origText = document.title, counter = 0; 101 | timer = setInterval(function() { 102 | document.title = ++counter % 2 == 1 ? text : origText; 103 | }, 1000); 104 | $(window).on("focus", function() { 105 | clearInterval(timer); 106 | timer = 0; 107 | document.title = origText; 108 | }); 109 | } 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /examples/messenger/inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ken107/push-model/4ebae68fe25c3222bc85d3a329064ab20bd6a41b/examples/messenger/inactive.png -------------------------------------------------------------------------------- /examples/messenger/messenger.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 20 | 23 | 24 | 25 | 26 | 27 | ID: 28 | Name: 29 | 30 | 31 | 32 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /examples/messenger/model.js: -------------------------------------------------------------------------------- 1 | var http = require("http"), 2 | server = http.createServer(), 3 | pm = require("../../dist/pushmodel.js"); 4 | 5 | server.listen(8085); 6 | pm.mount(server, "/messaging", new Model()); 7 | 8 | 9 | function Model() { 10 | this.users = pm.trackKeys({}); 11 | 12 | this.onConnect = function() { 13 | this.session = {}; 14 | }; 15 | 16 | this.signIn = function(userInfo) { 17 | var user = this.users[userInfo.id]; 18 | if (user) { 19 | user.name = userInfo.name; 20 | user.sessions++; 21 | } 22 | else { 23 | user = this.users[userInfo.id] = { 24 | id: userInfo.id, 25 | name: userInfo.name, 26 | sessions: 1, 27 | _state: {showMessenger: false}, 28 | _conversations: pm.trackKeys({}) 29 | }; 30 | } 31 | this.session._user = user; 32 | this.session.state = user._state; 33 | this.session.conversations = user._conversations; 34 | }; 35 | 36 | this.onDisconnect = function() { 37 | if (this.session._user) this.session._user.sessions--; 38 | }; 39 | 40 | this.showMessenger = function(show) { 41 | this.session.state.showMessenger = show; 42 | }; 43 | 44 | this.openChat = function(otherUserId) { 45 | if (otherUserId == this.session._user.id) return; 46 | if (this.session.conversations[otherUserId]) { 47 | this.session.conversations[otherUserId].open = true; 48 | } 49 | else { 50 | var log = []; 51 | log._lastModified = Date.now(); 52 | this.session.conversations[otherUserId] = {log: log, open: true}; 53 | this.users[otherUserId]._conversations[this.session._user.id] = {log: log}; 54 | } 55 | }; 56 | 57 | this.sendChat = function(otherUserId, message) { 58 | var log = this.session.conversations[otherUserId].log; 59 | log.push({ 60 | sender: this.session._user.id, 61 | text: message, 62 | time: (log._lastModified < Date.now()-5*60*1000) ? Date.now() : undefined 63 | }); 64 | log._lastModified = Date.now(); 65 | this.users[otherUserId]._conversations[this.session._user.id].open = true; 66 | }; 67 | 68 | this.closeChat = function(otherUserId) { 69 | this.session.conversations[otherUserId].open = false; 70 | }; 71 | 72 | this.resizeChat = function(otherUserId, size) { 73 | this.session.conversations[otherUserId].size = size; 74 | }; 75 | 76 | this.moveChat = function(otherUserId, position) { 77 | this.session.conversations[otherUserId].position = position; 78 | }; 79 | } 80 | 81 | exports.shutdown = () => server.close(); 82 | -------------------------------------------------------------------------------- /examples/messenger/model.test.js: -------------------------------------------------------------------------------- 1 | const { shutdown } = require("./model.js"); 2 | const { TestClient, makeReq, makeRes, beforeAll, afterAll, test, expect } = require("../../dist/util.js"); 3 | 4 | const c1 = new TestClient(); 5 | const c2 = new TestClient(); 6 | 7 | beforeAll(async () => { 8 | await c1.connect("ws://localhost:8085/messaging"); 9 | await c2.connect("ws://localhost:8085/messaging"); 10 | }) 11 | 12 | afterAll(() => { 13 | shutdown(); 14 | c1.close(); 15 | c2.close(); 16 | }) 17 | 18 | test("only one", async () => { 19 | let id = 0; 20 | let output; 21 | 22 | //User1 signin 23 | c1.send(makeReq(++id, "SUB", ["/users"])); 24 | expect(await c1.receive()).toEqual(makeRes(id)); 25 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"replace", path:"/users", value:{keys:[]}}]])); 26 | 27 | c1.send(makeReq(++id, "SUB", ["/session"])); 28 | expect(await c1.receive()).toEqual(makeRes(id)); 29 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"replace", path:"/session", value:{}}]])); 30 | 31 | c1.send(makeReq(++id, "signIn", [{id:1, name:"John"}])); 32 | expect(await c1.receive()).toEqual(makeRes(id)); 33 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[ 34 | {op:"splice", path:"/users/keys/0", remove:0, add:["1"]}, 35 | {op:"add", path:"/users/1", value:{id:1, name:"John", sessions:1}}, 36 | {op:"add", path:"/session/state", value:{showMessenger: false}}, 37 | {op:"add", path:"/session/conversations", value:{keys:[]}} 38 | ]])); 39 | 40 | //User2 signin 41 | c2.send(makeReq(++id, "SUB", ["/users"])); 42 | expect(await c2.receive()).toEqual(makeRes(id)); 43 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"replace", path:"/users", value:{keys:["1"], "1":{id:1, name:"John", sessions:1}}}]])); 44 | 45 | c2.send(makeReq(++id, "SUB", ["/session"])); 46 | expect(await c2.receive()).toEqual(makeRes(id)); 47 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"replace", path:"/session", value:{}}]])); 48 | 49 | c2.send(makeReq(++id, "signIn", [{id:2, name:"Lucy"}])); 50 | expect(await c2.receive()).toEqual(makeRes(id)); 51 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[ 52 | {op:"splice", path:"/users/keys/1", remove:0, add:["2"]}, 53 | {op:"add", path:"/users/2", value:{id:2, name:"Lucy", sessions:1}}, 54 | {op:"add", path:"/session/state", value:{showMessenger: false}}, 55 | {op:"add", path:"/session/conversations", value:{keys:[]}} 56 | ]])); 57 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[ 58 | {op:"splice", path:"/users/keys/1", remove:0, add:["2"]}, 59 | {op:"add", path:"/users/2", value:{id:2, name:"Lucy", sessions:1}} 60 | ]])); 61 | 62 | //User2 opens chat with User1 63 | c2.send(makeReq(++id, "openChat", [1])); 64 | expect(await c2.receive()).toEqual(makeRes(id)); 65 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[ 66 | {op:"splice", path:"/session/conversations/keys/0", remove:0, add:["1"]}, 67 | {op:"add", path:"/session/conversations/1", value:{log:[], open:true}} 68 | ]])); 69 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[ 70 | {op:"splice", path:"/session/conversations/keys/0", remove:0, add:["2"]}, 71 | {op:"add", path:"/session/conversations/2", value:{log:[]}} 72 | ]])) 73 | 74 | //User2 sends chat message to User1 75 | c2.send(makeReq(++id, "sendChat", [1, "Hey, you there?"])); 76 | expect(await c2.receive()).toEqual(makeRes(id)); 77 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[ 78 | {op:"splice", path:"/session/conversations/1/log/0", remove:0, add:[{sender:2, text:"Hey, you there?"}]} 79 | ]])) 80 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[ 81 | {op:"splice", path:"/session/conversations/2/log/0", remove:0, add:[{sender:2, text:"Hey, you there?"}]}, 82 | {op:"add", path:"/session/conversations/2/open", value:true} 83 | ]])) 84 | 85 | //User1 replies 86 | c1.send(makeReq(++id, "sendChat", [2, "Yeah, I'm here"])); 87 | expect(await c1.receive()).toEqual(makeRes(id)); 88 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[ 89 | {op:"splice", path:"/session/conversations/2/log/1", remove:0, add:[{sender:1, text:"Yeah, I'm here"}]} 90 | ]])) 91 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[ 92 | {op:"splice", path:"/session/conversations/1/log/1", remove:0, add:[{sender:1, text:"Yeah, I'm here"}]}, 93 | ]])) 94 | 95 | //User1 leaves 96 | c1.close(); 97 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[ 98 | {op:"add", path:"/users/1/sessions", value:0} 99 | ]])) 100 | }) 101 | -------------------------------------------------------------------------------- /examples/messenger/resize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ken107/push-model/4ebae68fe25c3222bc85d3a329064ab20bd6a41b/examples/messenger/resize.png -------------------------------------------------------------------------------- /examples/sharedtodolist/components.html: -------------------------------------------------------------------------------- 1 | 4 | {{#filter}} 9 | 10 | 11 | 12 | 14 | X 16 | 19 | {{#text}} 23 | 29 | 30 | 31 | 32 | 35 | TODO 36 | 39 | 40 | 41 | 42 | 45 | 47 | 48 | 60 | 61 | 62 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /examples/sharedtodolist/model.js: -------------------------------------------------------------------------------- 1 | var http = require("http"), 2 | server = http.createServer(), 3 | pm = require("../../dist/pushmodel.js"); 4 | 5 | server.listen(8085); 6 | 7 | pm.mount(server, "/todo", { 8 | items: [], 9 | addItem: function(text) { 10 | this.items.push({text: text}); 11 | }, 12 | deleteItem: function(index) { 13 | this.items.splice(index,1); 14 | }, 15 | setCompleted: function(index, completed) { 16 | this.items[index].completed = completed; 17 | }, 18 | setAllCompleted: function(completed) { 19 | for (var i=0; i server.close(); 34 | -------------------------------------------------------------------------------- /examples/sharedtodolist/model.test.js: -------------------------------------------------------------------------------- 1 | const { shutdown } = require("./model.js"); 2 | const { TestClient, makeReq, makeRes, beforeAll, afterAll, test, expect } = require("../../dist/util.js"); 3 | 4 | const c1 = new TestClient(); 5 | const c2 = new TestClient(); 6 | 7 | beforeAll(async () => { 8 | await c1.connect("ws://localhost:8085/todo"); 9 | await c2.connect("ws://localhost:8085/todo"); 10 | }) 11 | 12 | afterAll(() => { 13 | shutdown(); 14 | c1.close(); 15 | c2.close(); 16 | }) 17 | 18 | test("only one", async () => { 19 | let id = 0; 20 | let output; 21 | 22 | c1.send(makeReq(++id, "SUB", ["/items"])); 23 | expect(await c1.receive()).toEqual(makeRes(id)); 24 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"replace", path:"/items", value:[]}]])); 25 | 26 | c2.send(makeReq(++id, "SUB", ["/items"])); 27 | expect(await c2.receive()).toEqual(makeRes(id)); 28 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"replace", path:"/items", value:[]}]])); 29 | 30 | c1.send(makeReq(++id, "addItem", ["Pick John up at airport"])); 31 | expect(await c1.receive()).toEqual(makeRes(id)); 32 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"splice", path:"/items/0", remove:0, add:[{text: "Pick John up at airport"}]}]])); 33 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"splice", path:"/items/0", remove:0, add:[{text: "Pick John up at airport"}]}]])); 34 | 35 | c2.send(makeReq(++id, "addItem", ["Groceries"])); 36 | expect(await c2.receive()).toEqual(makeRes(id)); 37 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"splice", path:"/items/1", remove:0, add:[{text: "Groceries"}]}]])); 38 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"splice", path:"/items/1", remove:0, add:[{text: "Groceries"}]}]])); 39 | 40 | c1.send(makeReq(++id, "setCompleted", [1, true])); 41 | expect(await c1.receive()).toEqual(makeRes(id)); 42 | expect(await c1.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"add", path:"/items/1/completed", value:true}]])); 43 | expect(await c2.receive()).toEqual(makeReq(undefined, "PUB", [[{op:"add", path:"/items/1/completed", value:true}]])); 44 | }) 45 | -------------------------------------------------------------------------------- /examples/sharedtodolist/todo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 14 | 15 | 16 | 37 | 56 | 57 | 58 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push-model", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "push-model", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@types/node": "^20.14.4", 13 | "json-pointer": "^0.6.2", 14 | "jsonpatch-observe": "^1.0.0", 15 | "ws": "^8.17.1" 16 | }, 17 | "devDependencies": { 18 | "@types/json-pointer": "^1.0.34", 19 | "@types/ws": "^8.5.10", 20 | "typescript": "^5.4.5" 21 | }, 22 | "engines": { 23 | "node": ">=6.4.0" 24 | } 25 | }, 26 | "node_modules/@types/json-pointer": { 27 | "version": "1.0.34", 28 | "resolved": "https://registry.npmjs.org/@types/json-pointer/-/json-pointer-1.0.34.tgz", 29 | "integrity": "sha512-JRnWcxzXSaLei98xgw1B7vAeBVOrkyw0+Rt9j1QoJrczE78OpHsyQC8GNbuhw+/2vxxDe58QvWnngS86CoIbRg==", 30 | "dev": true 31 | }, 32 | "node_modules/@types/node": { 33 | "version": "20.14.4", 34 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.4.tgz", 35 | "integrity": "sha512-1ChboN+57suCT2t/f8lwtPY/k3qTpuD/qnqQuYoBg6OQOcPyaw7PiZVdGpaZYAvhDDtqrt0oAaM8+oSu1xsUGw==", 36 | "dependencies": { 37 | "undici-types": "~5.26.4" 38 | } 39 | }, 40 | "node_modules/@types/ws": { 41 | "version": "8.5.10", 42 | "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", 43 | "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", 44 | "dev": true, 45 | "dependencies": { 46 | "@types/node": "*" 47 | } 48 | }, 49 | "node_modules/foreach": { 50 | "version": "2.0.5", 51 | "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", 52 | "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=" 53 | }, 54 | "node_modules/json-pointer": { 55 | "version": "0.6.2", 56 | "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", 57 | "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", 58 | "dependencies": { 59 | "foreach": "^2.0.4" 60 | } 61 | }, 62 | "node_modules/jsonpatch-observe": { 63 | "version": "1.0.0", 64 | "resolved": "https://registry.npmjs.org/jsonpatch-observe/-/jsonpatch-observe-1.0.0.tgz", 65 | "integrity": "sha512-Sn6XeHMoPKeHYqqgzpDuGyHIuAYmGfhbd4LN+prbEvsRdzuxYL99UWSMYaReo1NVX06ClXoDWFrTFEjhWrFsfQ==", 66 | "engines": { 67 | "node": ">=6.4.0" 68 | } 69 | }, 70 | "node_modules/typescript": { 71 | "version": "5.4.5", 72 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", 73 | "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", 74 | "dev": true, 75 | "bin": { 76 | "tsc": "bin/tsc", 77 | "tsserver": "bin/tsserver" 78 | }, 79 | "engines": { 80 | "node": ">=14.17" 81 | } 82 | }, 83 | "node_modules/undici-types": { 84 | "version": "5.26.5", 85 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 86 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" 87 | }, 88 | "node_modules/ws": { 89 | "version": "8.17.1", 90 | "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", 91 | "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", 92 | "engines": { 93 | "node": ">=10.0.0" 94 | }, 95 | "peerDependencies": { 96 | "bufferutil": "^4.0.1", 97 | "utf-8-validate": ">=5.0.2" 98 | }, 99 | "peerDependenciesMeta": { 100 | "bufferutil": { 101 | "optional": true 102 | }, 103 | "utf-8-validate": { 104 | "optional": true 105 | } 106 | } 107 | } 108 | }, 109 | "dependencies": { 110 | "@types/json-pointer": { 111 | "version": "1.0.34", 112 | "resolved": "https://registry.npmjs.org/@types/json-pointer/-/json-pointer-1.0.34.tgz", 113 | "integrity": "sha512-JRnWcxzXSaLei98xgw1B7vAeBVOrkyw0+Rt9j1QoJrczE78OpHsyQC8GNbuhw+/2vxxDe58QvWnngS86CoIbRg==", 114 | "dev": true 115 | }, 116 | "@types/node": { 117 | "version": "20.14.4", 118 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.4.tgz", 119 | "integrity": "sha512-1ChboN+57suCT2t/f8lwtPY/k3qTpuD/qnqQuYoBg6OQOcPyaw7PiZVdGpaZYAvhDDtqrt0oAaM8+oSu1xsUGw==", 120 | "requires": { 121 | "undici-types": "~5.26.4" 122 | } 123 | }, 124 | "@types/ws": { 125 | "version": "8.5.10", 126 | "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", 127 | "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", 128 | "dev": true, 129 | "requires": { 130 | "@types/node": "*" 131 | } 132 | }, 133 | "foreach": { 134 | "version": "2.0.5", 135 | "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", 136 | "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=" 137 | }, 138 | "json-pointer": { 139 | "version": "0.6.2", 140 | "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", 141 | "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", 142 | "requires": { 143 | "foreach": "^2.0.4" 144 | } 145 | }, 146 | "jsonpatch-observe": { 147 | "version": "1.0.0", 148 | "resolved": "https://registry.npmjs.org/jsonpatch-observe/-/jsonpatch-observe-1.0.0.tgz", 149 | "integrity": "sha512-Sn6XeHMoPKeHYqqgzpDuGyHIuAYmGfhbd4LN+prbEvsRdzuxYL99UWSMYaReo1NVX06ClXoDWFrTFEjhWrFsfQ==" 150 | }, 151 | "typescript": { 152 | "version": "5.4.5", 153 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", 154 | "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", 155 | "dev": true 156 | }, 157 | "undici-types": { 158 | "version": "5.26.5", 159 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 160 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" 161 | }, 162 | "ws": { 163 | "version": "8.17.1", 164 | "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", 165 | "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", 166 | "requires": {} 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push-model", 3 | "version": "1.0.0", 4 | "description": "A JSON-RPC server with object synchronization based on JSON-Patch", 5 | "keywords": [ 6 | "mvc", 7 | "model", 8 | "push", 9 | "synchronization", 10 | "observe", 11 | "jsonpatch", 12 | "firebase" 13 | ], 14 | "license": "MIT", 15 | "author": "Hai Phan ", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/ken107/push-model" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/ken107/push-model/issues" 22 | }, 23 | "main": "dist/pushmodel.js", 24 | "types": "dist/pushmodel.d.ts", 25 | "files": [ 26 | "dist/pushmodel.js", 27 | "dist/pushmodel.d.ts" 28 | ], 29 | "scripts": { 30 | "build": "tsc", 31 | "test": "node ./examples/chat/model.test.js && node ./examples/messenger/model.test.js && node ./examples/sharedtodolist/model.test.js" 32 | }, 33 | "dependencies": { 34 | "@types/node": "^20.14.4", 35 | "json-pointer": "^0.6.2", 36 | "jsonpatch-observe": "^1.0.0", 37 | "ws": "^8.17.1" 38 | }, 39 | "engines": { 40 | "node": ">=6.4.0" 41 | }, 42 | "devDependencies": { 43 | "@types/json-pointer": "^1.0.34", 44 | "@types/ws": "^8.5.10", 45 | "typescript": "^5.4.5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/pushmodel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Push Model 3 | * Copyright 2018, Hai Phan 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | import { Server, IncomingMessage, ServerResponse } from "http"; 9 | import { parse as parseUrl } from "url"; 10 | import WebSocket from "ws"; 11 | import { has as hasPointer, get as getPointer } from "json-pointer"; 12 | import { observe, config as observeOpts, Patch, Subscriber } from "jsonpatch-observe"; 13 | 14 | export const options = Object.assign(observeOpts, { 15 | enableSplice: true, 16 | excludeProperty: (target: any, prop: string) => typeof prop == "string" && prop.startsWith('_') 17 | }) 18 | 19 | export class ErrorResponse { 20 | constructor(public code: number, public message: string, public data: string) { } 21 | } 22 | 23 | type RpcRequestId = number | string; 24 | 25 | type RpcRequest = { 26 | jsonrpc: string, 27 | method: string, 28 | params?: any, 29 | id?: RpcRequestId 30 | } 31 | 32 | type RpcResponse = { 33 | jsonrpc: string, 34 | id: RpcRequestId|null, 35 | result?: any, 36 | error?: any 37 | } 38 | 39 | type RpcMessage = RpcRequest|RpcResponse; 40 | 41 | export function mount(server: Server, path: string, model: any, acceptOrigins: Array) { 42 | model = observe(model); 43 | 44 | server.on("request", function(req: IncomingMessage, res: ServerResponse) { 45 | if (parseUrl(req.url!).pathname == path) { 46 | if (req.headers.origin) { 47 | const url = require("url").parse(req.headers.origin); 48 | if (acceptOrigins == null) res.setHeader("Access-Control-Allow-Origin", "*"); 49 | else if (acceptOrigins.indexOf(url.hostname) != -1) res.setHeader("Access-Control-Allow-Origin", req.headers.origin); 50 | } 51 | if (req.method == "POST") { 52 | let text = ''; 53 | req.setEncoding('utf8'); 54 | req.on('data', chunk => text += chunk); 55 | req.on('end', () => new RequestHandler(null, model, send).handle(text)); 56 | } 57 | else if (req.method == "OPTIONS" && req.headers["access-control-request-method"]) { 58 | res.setHeader("Access-Control-Allow-Methods", "POST"); 59 | res.end(); 60 | } 61 | else { 62 | res.writeHead(405, "Method Not Allowed"); 63 | res.end(); 64 | } 65 | } 66 | function send(message: RpcMessage) { 67 | res.setHeader("Content-Type", "application/json"); 68 | res.end(serialize(message), "utf8"); 69 | } 70 | }) 71 | 72 | const wss = new WebSocket.Server({ 73 | server: server, 74 | path: path, 75 | verifyClient: function(info, callback) { 76 | const origin = info.origin ? require("url").parse(info.origin).hostname : null; 77 | if (acceptOrigins == null || acceptOrigins.indexOf(origin) != -1) { 78 | if (callback) callback(true); 79 | else return true; 80 | } 81 | else { 82 | if (callback) callback(false, 403, "Forbidden"); 83 | else return false; 84 | } 85 | } 86 | }) 87 | 88 | wss.on("connection", function(ws: WebSocket) { 89 | let session: any = {}; 90 | const subman = new SubMan(model, send); 91 | onReceive('{"jsonrpc":"2.0","method":"onConnect"}'); 92 | ws.on("message", onReceive); 93 | ws.on("close", onClose); 94 | 95 | async function onReceive(text: string) { 96 | model.session = session; 97 | await new RequestHandler(subman, model, send).handle(text); 98 | session = model.session; 99 | model.session = null; 100 | } 101 | function onClose() { 102 | subman.unsubscribeAll(); 103 | onReceive('{"jsonrpc":"2.0","method":"onDisconnect"}'); 104 | } 105 | function send(message: RpcMessage) { 106 | ws.send(serialize(message), function(err?: Error) { 107 | if (err) console.log(err.stack || err); 108 | }); 109 | } 110 | }) 111 | 112 | function serialize(message: any) { 113 | return JSON.stringify(message, function(key, value) { 114 | return key != '' && observeOpts.excludeProperty(this, key) ? undefined : value; 115 | }) 116 | } 117 | } 118 | 119 | 120 | class RequestHandler { 121 | countResponses: number; 122 | responses: Array; 123 | constructor(private subman: SubMan|null, private model: any, private send: (message: any) => void) { 124 | this.countResponses = 0; 125 | this.responses = []; 126 | } 127 | async handle(text: string) { 128 | try { 129 | const data: any = JSON.parse(text); 130 | const requests: Array = Array.isArray(data) ? (data as Array) : [data as RpcRequest]; 131 | this.countResponses = requests.reduce(function(sum: number, request: RpcRequest) {return request.id !== undefined ? sum+1 : sum}, 0); 132 | for (const request of requests) await this.handleRequest(request); 133 | } 134 | catch (err) { 135 | console.error(err); 136 | this.countResponses = 1; 137 | this.sendError(null, -32700, "Parse error"); 138 | } 139 | } 140 | async handleRequest(request: RpcRequest) { 141 | if (request.jsonrpc != "2.0") { 142 | this.sendError(request.id, -32600, "Invalid request", "Not JSON-RPC version 2.0"); 143 | return; 144 | } 145 | let func: any; 146 | switch (request.method) { 147 | case "SUB": if (this.subman) func = this.subman.subscribe.bind(this.subman); break; 148 | case "UNSUB": if (this.subman) func = this.subman.unsubscribe.bind(this.subman); break; 149 | default: func = this.model[request.method]; 150 | } 151 | if (!(func instanceof Function)) { 152 | this.sendError(request.id, -32601, "Method not found"); 153 | return; 154 | } 155 | try { 156 | const result: any = await func.apply(this.model, request.params||[]); 157 | if (result instanceof ErrorResponse) this.sendError(request.id, result.code, result.message, result.data); 158 | else this.sendResult(request.id, result); 159 | } 160 | catch (err) { 161 | console.log(err); 162 | this.sendError(request.id, -32603, "Internal error"); 163 | } 164 | } 165 | sendResult(id: RpcRequestId|undefined, result: any) { 166 | if (id !== undefined) this.sendResponse({jsonrpc: "2.0", id: id, result: result}); 167 | } 168 | sendError(id: RpcRequestId|null|undefined, code: number, message: string, data?: string) { 169 | if (id !== undefined) this.sendResponse({jsonrpc: "2.0", id: id, error: {code: code, message: message, data: data}}); 170 | } 171 | sendResponse(response: RpcResponse) { 172 | this.responses.push(response); 173 | if (this.responses.length == this.countResponses) this.send(this.responses.length == 1 ? this.responses[0] : this.responses); 174 | } 175 | } 176 | 177 | var idGen: number = 0; 178 | 179 | 180 | class SubMan { 181 | id: number; 182 | subscriptions: any; 183 | pendingPatches: Array; 184 | constructor(private model: any, private send: (response: RpcMessage) => void) { 185 | this.id = idGen = (idGen || 0) + 1; 186 | this.subscriptions = {}; 187 | this.pendingPatches = []; 188 | } 189 | subscribe(pointer: string) { 190 | if (pointer == null) return new ErrorResponse(-32602, "Invalid params", "Missing param 'pointer'"); 191 | if (typeof pointer != "string") return new ErrorResponse(-32602, "Invalid params", "Pointer must be a string"); 192 | if (pointer == "") return new ErrorResponse(-32602, "Invalid params", "Cannot subscribe to the root model object"); 193 | if (this.subscriptions[pointer]) this.subscriptions[pointer].count++; 194 | else { 195 | if (!hasPointer(this.model, pointer)) return new ErrorResponse(0, "Application error", "Can't subscribe to '" + pointer + "', path not found"); 196 | const obj = getPointer(this.model, pointer); 197 | if (!(obj instanceof Object)) return new ErrorResponse(0, "Application error", "Can't subscribe to '" + pointer + "', value is not an object"); 198 | this.onPatch(pointer, {op: "replace", path: "", value: obj}); 199 | this.subscriptions[pointer] = { 200 | target: obj, 201 | callback: this.onPatch.bind(this, pointer), 202 | count: 1 203 | }; 204 | obj.$subscribe(this.subscriptions[pointer].callback); 205 | } 206 | }; 207 | unsubscribe(pointer: string) { 208 | if (pointer == null) return new ErrorResponse(-32602, "Invalid params", "Missing param 'pointer'"); 209 | if (typeof pointer != "string") return new ErrorResponse(-32602, "Invalid params", "Pointer must be a string"); 210 | if (this.subscriptions[pointer]) { 211 | this.subscriptions[pointer].count--; 212 | if (this.subscriptions[pointer].count <= 0) { 213 | this.subscriptions[pointer].target.$unsubscribe(this.subscriptions[pointer].callback); 214 | delete this.subscriptions[pointer]; 215 | } 216 | } 217 | } 218 | unsubscribeAll() { 219 | for (const pointer in this.subscriptions) this.subscriptions[pointer].target.$unsubscribe(this.subscriptions[pointer].callback); 220 | } 221 | onPatch(pointer: string, patch: Patch) { 222 | //console.log(this.id, pointer, patch); 223 | if (!this.pendingPatches.length) setTimeout(this.sendPendingPatches.bind(this), 0); 224 | this.pendingPatches.push(this.copyPatch(patch, pointer+patch.path)); 225 | } 226 | sendPendingPatches() { 227 | this.send({jsonrpc: "2.0", method: "PUB", params: [this.pendingPatches]}); 228 | this.pendingPatches = []; 229 | } 230 | copyPatch(patch: Patch, newPath: string) { 231 | switch (patch.op) { 232 | case "remove": return {op: patch.op, path: newPath}; 233 | case "splice": return {op: patch.op, path: newPath, remove: patch.remove, add: patch.add}; 234 | default: return {op: patch.op, path: newPath, value: patch.value}; 235 | } 236 | } 237 | } 238 | 239 | export function trackKeys(obj: any) { 240 | if (!obj.$handler) obj = observe(obj); 241 | if (!obj.keys) obj.keys = Object.keys(obj).filter(prop => !observeOpts.excludeProperty(obj, prop)); 242 | if (!obj._keysUpdater) obj.$subscribe(obj._keysUpdater = updateKeys.bind(null, obj)); 243 | return obj; 244 | } 245 | 246 | export function untrackKeys(obj: any) { 247 | if (obj._keysUpdater) { 248 | obj.$unsubscribe(obj._keysUpdater); 249 | delete obj._keysUpdater; 250 | } 251 | } 252 | 253 | function updateKeys(obj: any, patch: Patch) { 254 | const tokens = patch.path.split("/"); 255 | if (tokens.length == 2) { 256 | const prop = tokens[1]; 257 | if (!observeOpts.excludeProperty(obj, prop)) { 258 | if (patch.op == "add") { 259 | const index = obj.keys.indexOf(prop); 260 | if (index == -1) obj.keys.push(prop); 261 | } 262 | else if (patch.op == "remove") { 263 | const index = obj.keys.indexOf(prop); 264 | if (index != -1) obj.keys.splice(index, 1); 265 | } 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { isDeepStrictEqual } from "util"; 2 | import WebSocket from "ws"; 3 | 4 | const before: Array = [] 5 | const after: Array = [] 6 | 7 | export function beforeAll(f: Function) { 8 | before.push(f) 9 | } 10 | 11 | export function afterAll(f: Function) { 12 | after.push(f) 13 | } 14 | 15 | export async function test(name: string, run: Function) { 16 | console.log("Running test", name) 17 | for (const f of before) await f() 18 | await run() 19 | for (const f of after) await f() 20 | } 21 | 22 | export function expect(a: unknown) { 23 | return { 24 | toEqual(b: object) { 25 | if (!isDeepStrictEqual(a, b)) { 26 | console.log("Received", a) 27 | console.log("Expected", b) 28 | throw new Error("Assertion failed") 29 | } 30 | } 31 | } 32 | } 33 | 34 | export class TestClient { 35 | waiting: Array<(result: any) => void>; 36 | incoming: Array; 37 | ws?: WebSocket; 38 | constructor() { 39 | this.waiting = []; 40 | this.incoming = []; 41 | } 42 | connect(url: string) { 43 | return new Promise(fulfill => { 44 | this.ws = new WebSocket(url); 45 | this.ws.on("open", fulfill); 46 | this.ws.on("message", (text: string) => { 47 | this.incoming.push(JSON.parse(text)); 48 | while (this.incoming.length && this.waiting.length) this.waiting.shift()!(this.incoming.shift()); 49 | }) 50 | }) 51 | } 52 | send(req: any) { 53 | this.ws!.send(JSON.stringify(req)); 54 | } 55 | receive() { 56 | return new Promise(fulfill => { 57 | this.waiting.push(fulfill); 58 | while (this.incoming.length && this.waiting.length) this.waiting.shift()!(this.incoming.shift()); 59 | }) 60 | } 61 | close() { 62 | if (this.ws) this.ws.close(); 63 | } 64 | } 65 | 66 | export function makeReq(id: number, method: string, params: Array) { 67 | return { 68 | jsonrpc: "2.0", 69 | ...(id !== undefined ? {id} : null), 70 | method, 71 | params 72 | } 73 | } 74 | 75 | export function makeRes(id: number, result: any) { 76 | return { 77 | jsonrpc: "2.0", 78 | ...(id !== undefined ? {id} : null), 79 | ...(result !== undefined ? {result} : null) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "target": "ES2020", 5 | "strict": true, 6 | "moduleResolution": "NodeNext", 7 | "declaration": true, 8 | "newLine": "lf", 9 | "outDir": "dist" 10 | }, 11 | "include": [ 12 | "src/**/*" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------