@push-rpc/next
Advanced tools
Comparing version 2.0.11 to 2.0.12
@@ -11,3 +11,3 @@ import {Services} from "./api" | ||
console.log("Got todo items", todos) | ||
}, null) | ||
}) | ||
@@ -14,0 +14,0 @@ await remote.todo.addTodo({text: "Buy groceries"}) |
import {Todo, TodoService} from "./api" | ||
import {publishServices} from "../src/server/index" | ||
const storage: Todo[] = [] | ||
async function startServer() { | ||
const storage: Todo[] = [] | ||
async function startServer() { | ||
class TodoServiceImpl implements TodoService { | ||
@@ -8,0 +8,0 @@ async addTodo({text}: {text: string}) { |
{ | ||
"name": "@push-rpc/next", | ||
"version": "2.0.11", | ||
"version": "2.0.12", | ||
"main": "dist/index.js", | ||
@@ -5,0 +5,0 @@ "types": "dist/index.d.ts", |
234
README.md
@@ -1,3 +0,210 @@ | ||
Client/server framework | ||
A TypeScript framework for organizing bidirectional typesafe client-server communication. Supports | ||
server-initiated data push (subscriptions). Uses HTTP, JSON and, optionally, WebSockets. | ||
Main focus is on simplicity and good developer experience. | ||
Best used with monorepos using TypeScript. Can also be used with JavaScript and non-JS clients. | ||
## Features | ||
- Developer friendly - remote call is a plain TS call for easy tracing between client and server and good | ||
IDE integration. | ||
- Based on HTTP, easy to integrate with existing infrastructure. Call visibility in browser DevTools. | ||
- Gradually upgradeable - WS is only used when you need subscriptions. | ||
- Server runs on Node.JS, client runs in the Node.JS/Browser/ReactNative. | ||
Extra: | ||
- Client & Server middlewares. | ||
- Consume compressed HTTP requests. | ||
- Send & receive plain-text data. | ||
- Throttling for subscriptions. | ||
- Broken WS connection detection & auto-reconnecting. | ||
## History note! | ||
Initially this project supported WS-only communication akin to OCPP-J protocol for EV charging stations. | ||
Since that, library for OCPP-J was extracted to a separate project and this project was reworked to focus on generic | ||
client/server communication. | ||
## Getting Started | ||
``` | ||
npm install @push-rpc/next | ||
``` | ||
For implementing subscriptions at backend, you'll also need to install ws package: | ||
``` | ||
npm install ws | ||
``` | ||
Contract between server and client is defined in shared module. | ||
api.ts: | ||
``` | ||
// Note that API definition is plain TypeScript and is independent of the library | ||
export type Services = { | ||
todo: TodoService | ||
} | ||
export type TodoService = { | ||
addTodo(req: {text: string}, ctx?: any): Promise<void> | ||
getTodos(ctx?: any): Promise<Todo[]> | ||
} | ||
export type Todo = { | ||
id: string | ||
text: string | ||
status: "open" | "closed" | ||
} | ||
``` | ||
Contact then used in client.ts: | ||
``` | ||
import {Services} from "./api" | ||
import {consumeServices} from "@push-rpc/next" | ||
async function startClient() { | ||
const {remote} = await consumeServices<Services>("http://127.0.0.1:8080/rpc") | ||
console.log("Client created") | ||
await remote.todo.getTodos.subscribe((todos) => { | ||
console.log("Got todo items", todos) | ||
}) | ||
await remote.todo.addTodo({text: "Buy groceries"}) | ||
} | ||
startClient() | ||
``` | ||
And implemented in server.ts: | ||
``` | ||
import {Todo, TodoService} from "./api" | ||
import {publishServices} from "@push-rpc/next" | ||
async function startServer() { | ||
const storage: Todo[] = [] | ||
class TodoServiceImpl implements TodoService { | ||
async addTodo({text}: {text: string}) { | ||
storage.push({ | ||
id: "" + Math.random(), | ||
text, | ||
status: "open", | ||
}) | ||
console.log("New todo item added") | ||
services.todo.getTodos.trigger() | ||
} | ||
async getTodos() { | ||
return storage | ||
} | ||
} | ||
const {services} = await publishServices( | ||
{ | ||
todo: new TodoServiceImpl(), | ||
}, | ||
{ | ||
port: 8080, | ||
path: "/rpc", | ||
} | ||
) | ||
console.log("RPC Server started at http://localhost:8080/rpc") | ||
} | ||
startServer() | ||
``` | ||
Run server.ts and then client.ts. | ||
Server will send empty todo list on client connecting and then will send updated list on adding new item. | ||
## Protocol Details | ||
There are the types of messages that can be sent from client to server: | ||
1. **Call** - a request to synchronously execute a remote function. Implemented as HTTP POST request. URL contains the | ||
remote function name. Body contains JSON-encoded list of arguments. Response is JSON-encoded result of the | ||
function. | ||
``` | ||
POST /rpc/todo/addTodo HTTP/1.1 | ||
Content-Type: application/json | ||
X-Rpc-Client-Id: GoQ_xVYcthSEqMxDGV212 | ||
[{"text": "Buy groceries"}] | ||
... | ||
HTTP/1.1 200 OK | ||
Content-Type: application/json | ||
{"id": "123"} | ||
``` | ||
`X-Rpc-Client-Id` header is used to identify caller clients. In can be used for session tracking. Client ID is | ||
available at server side in the context. | ||
2. **Subscribe** - a request to subscribe to a remote function updates. Implemented as HTTP PUT request. URL contains | ||
the remote function name. Body contains JSON-encoded list of arguments. Response is JSON-encoded result of the | ||
function. | ||
``` | ||
PUT /rpc/todo/getTodos HTTP/1.1 | ||
Content-Type: application/json | ||
[] | ||
... | ||
HTTP/1.1 200 OK | ||
Content-Type: application/json | ||
[{"id": 1, text: "Buy groceries", status: "open"}] | ||
``` | ||
Before subscribing, client would establish a WebSocket connection to the server. Server would then use established | ||
connection to send subscription updates. Client ID is used to link WebSocket connection and HTTP requests. | ||
3. **Unsubscribe** - a request to unsubscribe from a remote function updates. Implemented as HTTP PATCH request. URL | ||
contains the remote function name. Body contains JSON-encoded list of arguments. Response is always empty. | ||
``` | ||
PATCH /rpc/todo/getTodos HTTP/1.1 | ||
Content-Type: application/json | ||
[] | ||
... | ||
HTTP/1.1 204 No Content | ||
``` | ||
Server sends updates to subscribed clients using WebSocket connection. Clients establish WebSocket connection using | ||
the base URL: | ||
``` | ||
GET /rpc HTTP/1.1 | ||
Connection: Upgrade | ||
Upgrade: websocket | ||
Sec-Websocket-Protocol: GoQ_xVYcthSEqMxDGV212 | ||
``` | ||
`Sec-Websocket-Protocol` header is used to transfer client ID. | ||
After successful subscription, server sends updates to the client. Each update is a JSON-encoded message containing | ||
topic name, remote function result and subscription parameters if any: | ||
``` | ||
["todo/getTodos", [{"id": 1, text: "Buy groceries", status: "open"}], ...] | ||
``` | ||
Both client & server will try to connect broken connections by sending WS ping/pongs. | ||
## Glossary | ||
@@ -22,6 +229,9 @@ | ||
**Middlewares**. Middlewares are used to intercept client and server requests. Both calls and subscriptions can be | ||
intercepted. Middlewares can be attached on both client and server side. Middlewares receive context as the last | ||
arguments in the invocation. Middleware can modify context. | ||
**Middlewares**. Middlewares are used to intercept client requests, server requests implementations and client | ||
notifications. Both calls and subscriptions can be intercepted. Middlewares can be attached on both client and server | ||
side. Middleware can modify context and parameters and data. | ||
Request middleware is called with parameters (ctx, next, ...parameters) | ||
Client notifications middleware is called with parameters (ctx, next, data, parameters). | ||
**Throttling**. Used to limit number of notifications from the remote functions. With throttling enabled, not all | ||
@@ -31,20 +241,4 @@ triggers will result in new notifications. Throttling can be used with reducers to aggregate values supplied in | ||
## Issues / TBDs | ||
- [important] Importing index.js from the root of the package will import node's http package. Not good for clients. | ||
- Browser sockets don't have 'ping' event. Need to find a different way to detect connection loss. | ||
- Перевірити, що throttling працює відразу для всіх підписників | ||
## Features | ||
- Developer friendly - everything is plain TypeScript calls, easy call tracing between client and server, good | ||
integration with IDE & Browser DevTools | ||
- Based on HTTP, easy to integrate with existing infrastructure | ||
- Gradually upgradeable - WS is only used when you need subscriptions | ||
- Supports compressed HTTP requests. | ||
- Server runs on Node.JS, client runs in the Node.JS/Browser/ReactNative. For RN some extra setup is required ( | ||
document). Bun/Deno should also work, but not officially supported. | ||
# Limitations | ||
- Cookies are not been sent during HTTP & WS requests. |
@@ -25,3 +25,3 @@ import {RpcContext, Services} from "../rpc.js" | ||
middleware: Middleware<RpcContext>[] | ||
updatesMiddleware: Middleware<RpcContext>[] | ||
notificationsMiddleware: Middleware<RpcContext>[] | ||
connectOnCreate: boolean | ||
@@ -69,3 +69,3 @@ onConnected: () => void | ||
middleware: [], | ||
updatesMiddleware: [], | ||
notificationsMiddleware: [], | ||
connectOnCreate: false, | ||
@@ -72,0 +72,0 @@ onConnected: () => {}, |
@@ -8,3 +8,3 @@ import {CallOptions, InvocationType, RpcContext, Services} from "../rpc.js" | ||
import {ConsumeServicesOptions, RpcClient} from "./index.js" | ||
import {withMiddlewares} from "../utils/middleware.js" | ||
import {Middleware, withMiddlewares} from "../utils/middleware.js" | ||
@@ -35,4 +35,12 @@ export class RpcClientImpl<S extends Services<S>> implements RpcClient { | ||
const next = async (p = data) => this.remoteSubscriptions.consume(itemName, parameters, p) | ||
return withMiddlewares(ctx, this.options.updatesMiddleware, next, data) | ||
const next = async (nextData = data, nextParameters = parameters) => | ||
this.remoteSubscriptions.consume(itemName, nextParameters, nextData) | ||
return withMiddlewares( | ||
ctx, | ||
this.options.notificationsMiddleware, | ||
next as any, | ||
data, | ||
parameters | ||
) | ||
}, | ||
@@ -39,0 +47,0 @@ () => { |
@@ -15,3 +15,7 @@ import {log} from "../logger.js" | ||
}, | ||
private readonly consume: (itemName: string, parameters: unknown[], data: unknown) => void, | ||
private readonly consume: ( | ||
itemName: string, | ||
parameters: unknown[], | ||
data: unknown | ||
) => Promise<unknown>, | ||
private readonly onConnected: () => void, | ||
@@ -198,5 +202,5 @@ private readonly onDisconnected: () => void | ||
this.consume(itemName, parameters, data) | ||
await this.consume(itemName, parameters, data) | ||
} catch (e) { | ||
log.warn("Invalid message received", e) | ||
log.error("Can't handle notification", e) | ||
} | ||
@@ -203,0 +207,0 @@ } |
@@ -28,7 +28,12 @@ export type Middleware<Context> = ( | ||
try { | ||
let result | ||
if (i === middlewares.length) { | ||
return Promise.resolve(next(...p)) | ||
result = next(...p) | ||
} else { | ||
return Promise.resolve(middlewares[i](ctx, dispatch.bind(null, i + 1), ...p)) | ||
const dispatchNextMiddleware = dispatch.bind(null, i + 1) | ||
result = middlewares[i](ctx, dispatchNextMiddleware, ...p) | ||
} | ||
return Promise.resolve(result) | ||
} catch (err) { | ||
@@ -35,0 +40,0 @@ return Promise.reject(err) |
import {assert} from "chai" | ||
import {Middleware, withMiddlewares} from "../src/index.js" | ||
import {Middleware, setLogger, withMiddlewares} from "../src/index.js" | ||
import {createTestClient, startTestServer} from "./testUtils.js" | ||
@@ -113,7 +113,7 @@ import {adelay} from "../src/utils/promises.js" | ||
it("updates middlewares", async () => { | ||
it("notifications middlewares", async () => { | ||
let count = 1 | ||
const services = await startTestServer({ | ||
async remote() { | ||
async remote(filter: {param: number}) { | ||
return count++ | ||
@@ -124,5 +124,6 @@ }, | ||
const client = await createTestClient<typeof services>({ | ||
updatesMiddleware: [ | ||
(ctx, next, r) => { | ||
notificationsMiddleware: [ | ||
(ctx, next, r, params) => { | ||
assert.ok(ctx.itemName) | ||
assert.deepEqual(params, [{param: 1}]) | ||
@@ -135,4 +136,47 @@ return next((r as number) + 1) | ||
let response | ||
await client.remote.subscribe( | ||
(r) => { | ||
response = r | ||
}, | ||
{param: 1} | ||
) | ||
services.remote.trigger({param: 1}) | ||
await adelay(20) | ||
assert.equal(response, 3) | ||
}) | ||
it("error in notificationsMiddleware logged", async () => { | ||
let log = "" | ||
setLogger({ | ||
error(s: unknown, ...params) { | ||
log += s + "\n" + params | ||
}, | ||
warn: console.warn, | ||
debug: console.debug, | ||
info: console.info, | ||
}) | ||
let count = 1 | ||
let result = 0 | ||
const services = await startTestServer({ | ||
async remote() { | ||
return count++ | ||
}, | ||
}) | ||
const client = await createTestClient<typeof services>({ | ||
notificationsMiddleware: [ | ||
() => { | ||
throw new Error("Test error") | ||
}, | ||
], | ||
}) | ||
await client.remote.subscribe((r) => { | ||
response = r | ||
result = r | ||
}) | ||
@@ -144,4 +188,57 @@ | ||
assert.equal(response, 3) | ||
assert.equal(result, 1) | ||
assert.include(log, "Test error") | ||
setLogger(console) | ||
}) | ||
it("error in client request middleware propagated", async () => { | ||
let count = 1 | ||
const services = await startTestServer({ | ||
async remote() { | ||
return count++ | ||
}, | ||
}) | ||
const client = await createTestClient<typeof services>({ | ||
middleware: [ | ||
() => { | ||
throw new Error("Error") | ||
}, | ||
], | ||
}) | ||
try { | ||
await client.remote() | ||
assert.fail("Expected to fail") | ||
} catch (e) {} | ||
}) | ||
it("error in server request middleware propagated", async () => { | ||
let count = 1 | ||
const services = await startTestServer( | ||
{ | ||
async remote() { | ||
return count++ | ||
}, | ||
}, | ||
{ | ||
middleware: [ | ||
() => { | ||
throw new Error("Error") | ||
}, | ||
], | ||
} | ||
) | ||
const client = await createTestClient<typeof services>({}) | ||
try { | ||
await client.remote() | ||
assert.fail("Expected to fail") | ||
} catch (e) {} | ||
}) | ||
}) |
@@ -42,4 +42,4 @@ import { | ||
options.middleware = [logMiddleware, ...options.middleware] | ||
if (!options.updatesMiddleware) options.updatesMiddleware = [] | ||
options.updatesMiddleware = [logUpdatesMiddleware, ...options.updatesMiddleware] | ||
if (!options.notificationsMiddleware) options.notificationsMiddleware = [] | ||
options.notificationsMiddleware = [logUpdatesMiddleware, ...options.notificationsMiddleware] | ||
@@ -46,0 +46,0 @@ const r = await consumeServices<S>(`http://127.0.0.1:${TEST_PORT}/rpc`, options) |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
242
0
155040
42
4500