@push-rpc/next 2.0.0-beta.1
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/.prettierrc.json +7 -0
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/client/HttpClient.d.ts +10 -0
- package/dist/client/HttpClient.js +67 -0
- package/dist/client/HttpClient.js.map +1 -0
- package/dist/client/RemoteSubscriptions.d.ts +14 -0
- package/dist/client/RemoteSubscriptions.js +84 -0
- package/dist/client/RemoteSubscriptions.js.map +1 -0
- package/dist/client/RpcClientImpl.d.ts +22 -0
- package/dist/client/RpcClientImpl.js +96 -0
- package/dist/client/RpcClientImpl.js.map +1 -0
- package/dist/client/WebSocketConnection.d.ts +33 -0
- package/dist/client/WebSocketConnection.js +152 -0
- package/dist/client/WebSocketConnection.js.map +1 -0
- package/dist/client/index.d.ts +22 -0
- package/dist/client/index.js +28 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/remote.d.ts +14 -0
- package/dist/client/remote.js +66 -0
- package/dist/client/remote.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.js +9 -0
- package/dist/logger.js.map +1 -0
- package/dist/rpc.d.ts +36 -0
- package/dist/rpc.js +32 -0
- package/dist/rpc.js.map +1 -0
- package/dist/server/ConnectionsServer.d.ts +13 -0
- package/dist/server/ConnectionsServer.js +60 -0
- package/dist/server/ConnectionsServer.js.map +1 -0
- package/dist/server/LocalSubscriptions.d.ts +12 -0
- package/dist/server/LocalSubscriptions.js +113 -0
- package/dist/server/LocalSubscriptions.js.map +1 -0
- package/dist/server/RpcServerImpl.d.ts +23 -0
- package/dist/server/RpcServerImpl.js +164 -0
- package/dist/server/RpcServerImpl.js.map +1 -0
- package/dist/server/http.d.ts +9 -0
- package/dist/server/http.js +83 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/index.d.ts +29 -0
- package/dist/server/index.js +31 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/local.d.ts +15 -0
- package/dist/server/local.js +46 -0
- package/dist/server/local.js.map +1 -0
- package/dist/utils/json.d.ts +2 -0
- package/dist/utils/json.js +34 -0
- package/dist/utils/json.js.map +1 -0
- package/dist/utils/middleware.d.ts +2 -0
- package/dist/utils/middleware.js +31 -0
- package/dist/utils/middleware.js.map +1 -0
- package/dist/utils/promises.d.ts +5 -0
- package/dist/utils/promises.js +29 -0
- package/dist/utils/promises.js.map +1 -0
- package/dist/utils/throttle.d.ts +4 -0
- package/dist/utils/throttle.js +40 -0
- package/dist/utils/throttle.js.map +1 -0
- package/dist/utils/types.d.ts +1 -0
- package/dist/utils/types.js +3 -0
- package/dist/utils/types.js.map +1 -0
- package/example/api.ts +15 -0
- package/example/client.ts +16 -0
- package/example/server.ts +37 -0
- package/package.json +34 -0
- package/src/client/HttpClient.ts +80 -0
- package/src/client/RemoteSubscriptions.ts +121 -0
- package/src/client/RpcClientImpl.ts +177 -0
- package/src/client/WebSocketConnection.ts +183 -0
- package/src/client/index.ts +56 -0
- package/src/client/remote.ts +118 -0
- package/src/index.ts +18 -0
- package/src/logger.ts +12 -0
- package/src/rpc.ts +51 -0
- package/src/server/ConnectionsServer.ts +78 -0
- package/src/server/LocalSubscriptions.ts +155 -0
- package/src/server/RpcServerImpl.ts +252 -0
- package/src/server/http.ts +109 -0
- package/src/server/index.ts +65 -0
- package/src/server/local.ts +80 -0
- package/src/utils/json.ts +32 -0
- package/src/utils/middleware.ts +38 -0
- package/src/utils/promises.ts +25 -0
- package/src/utils/throttle.ts +48 -0
- package/src/utils/types.ts +1 -0
- package/tests/calls.ts +215 -0
- package/tests/connection.ts +107 -0
- package/tests/context.ts +176 -0
- package/tests/middleware.ts +112 -0
- package/tests/misc.ts +187 -0
- package/tests/subscriptions.ts +442 -0
- package/tests/testUtils.ts +52 -0
- package/tests/triggers.ts +138 -0
- package/tsconfig.cjs.json +20 -0
- package/tsconfig.json +26 -0
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {ExtractPromiseResult} from "./utils/types.js"
|
|
2
|
+
|
|
3
|
+
export type RemoteFunction = (...args: any[]) => Promise<any>
|
|
4
|
+
export type Services<SubType> = {
|
|
5
|
+
[K in keyof SubType]: Services<SubType[K]> | RemoteFunction
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type Consumer<T extends RemoteFunction> = (data: ExtractPromiseResult<ReturnType<T>>) => void
|
|
9
|
+
|
|
10
|
+
export class RpcError extends Error {
|
|
11
|
+
constructor(
|
|
12
|
+
public readonly code: number,
|
|
13
|
+
message?: string
|
|
14
|
+
) {
|
|
15
|
+
super(message)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export enum RpcErrors {
|
|
20
|
+
NotFound = 404,
|
|
21
|
+
Timeout = 504,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class CallOptions {
|
|
25
|
+
constructor(options: {timeout: number}) {
|
|
26
|
+
this.timeout = options.timeout
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public readonly timeout
|
|
30
|
+
public readonly kind = CallOptions.KIND // to distinguish from other parameters in remote call
|
|
31
|
+
|
|
32
|
+
public static KIND = "CallOptions"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const CLIENT_ID_HEADER = "x-rpc-client-id"
|
|
36
|
+
|
|
37
|
+
export type RpcConnectionContext = {
|
|
38
|
+
clientId: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type RpcContext = RpcConnectionContext & {
|
|
42
|
+
itemName: string
|
|
43
|
+
invocationType: InvocationType
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export enum InvocationType {
|
|
47
|
+
Call = "Call",
|
|
48
|
+
Subscribe = "Subscribe",
|
|
49
|
+
Unsubscribe = "Unsubscribe", // client only
|
|
50
|
+
Trigger = "Trigger", // server only
|
|
51
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {safeStringify} from "../utils/json.js"
|
|
2
|
+
import WebSocket, {WebSocketServer} from "ws"
|
|
3
|
+
import http from "http"
|
|
4
|
+
import {log} from "../logger.js"
|
|
5
|
+
|
|
6
|
+
export class ConnectionsServer {
|
|
7
|
+
constructor(
|
|
8
|
+
server: http.Server,
|
|
9
|
+
options: ConnectionsServerOptions,
|
|
10
|
+
connectionClosed: (clientId: string) => void
|
|
11
|
+
) {
|
|
12
|
+
this.wss = new WebSocketServer({server, path: options.path})
|
|
13
|
+
|
|
14
|
+
this.wss.on("connection", (ws: WebSocket & {alive: boolean}) => {
|
|
15
|
+
ws.alive = true
|
|
16
|
+
|
|
17
|
+
const clientId = ws.protocol || "anon"
|
|
18
|
+
this.clientSockets.set(clientId, ws)
|
|
19
|
+
|
|
20
|
+
ws.on("error", (e: unknown) => {
|
|
21
|
+
log.error("Error in WS", e)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
ws.on("close", () => {
|
|
25
|
+
this.clientSockets.delete(clientId)
|
|
26
|
+
connectionClosed(clientId)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
ws.on("pong", () => {
|
|
30
|
+
ws.alive = true
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const pingTimer = setInterval(() => {
|
|
35
|
+
this.clientSockets.forEach((ws) => {
|
|
36
|
+
if (!ws.alive) {
|
|
37
|
+
// missing 2nd keep-alive period
|
|
38
|
+
ws.terminate()
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
ws.alive = false
|
|
43
|
+
ws.ping()
|
|
44
|
+
})
|
|
45
|
+
}, options.pingInterval)
|
|
46
|
+
|
|
47
|
+
this.wss.on("close", () => {
|
|
48
|
+
clearInterval(pingTimer)
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
publish(clientId: string, itemName: string, parameters: unknown[], data: unknown) {
|
|
53
|
+
const message = [itemName, data, ...parameters]
|
|
54
|
+
|
|
55
|
+
const ws = this.clientSockets.get(clientId)
|
|
56
|
+
|
|
57
|
+
if (ws) {
|
|
58
|
+
ws.send(safeStringify(message))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private wss: WebSocketServer
|
|
63
|
+
private clientSockets = new Map<string, WebSocket & {alive: boolean}>()
|
|
64
|
+
|
|
65
|
+
async close() {
|
|
66
|
+
return new Promise<void>((resolve, reject) => {
|
|
67
|
+
this.wss.close((err) => {
|
|
68
|
+
if (err) reject(err)
|
|
69
|
+
else resolve()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ConnectionsServerOptions = {
|
|
76
|
+
pingInterval: number
|
|
77
|
+
path: string
|
|
78
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {safeStringify} from "../utils/json.js"
|
|
2
|
+
import {lastValueReducer, throttle} from "../utils/throttle.js"
|
|
3
|
+
import {ThrottleSettings} from "./local.js"
|
|
4
|
+
|
|
5
|
+
export class LocalSubscriptions {
|
|
6
|
+
subscribe(
|
|
7
|
+
clientId: string,
|
|
8
|
+
itemName: string,
|
|
9
|
+
parameters: unknown[],
|
|
10
|
+
update: (suppliedData?: unknown) => void
|
|
11
|
+
) {
|
|
12
|
+
const itemSubscriptions = this.byItem.get(itemName) || {byFilter: new Map()}
|
|
13
|
+
this.byItem.set(itemName, itemSubscriptions)
|
|
14
|
+
|
|
15
|
+
const filterKey = getFilterKey(parameters)
|
|
16
|
+
|
|
17
|
+
const subscriptions: FilterSubscription = itemSubscriptions.byFilter.get(filterKey) || {
|
|
18
|
+
filter: parameters?.[0],
|
|
19
|
+
subscribedClients: [],
|
|
20
|
+
}
|
|
21
|
+
itemSubscriptions.byFilter.set(filterKey, subscriptions)
|
|
22
|
+
|
|
23
|
+
if (!subscriptions.subscribedClients.some((c) => c.clientId == clientId)) {
|
|
24
|
+
subscriptions.subscribedClients.push({clientId, update})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
unsubscribe(clientId: string, itemName: string, parameters: unknown[]) {
|
|
29
|
+
const itemSubscriptions = this.byItem.get(itemName)
|
|
30
|
+
if (!itemSubscriptions) return
|
|
31
|
+
|
|
32
|
+
const filterKey = getFilterKey(parameters)
|
|
33
|
+
|
|
34
|
+
const subscriptions = itemSubscriptions.byFilter.get(filterKey)
|
|
35
|
+
if (!subscriptions) return
|
|
36
|
+
|
|
37
|
+
subscriptions.subscribedClients = subscriptions.subscribedClients.filter(
|
|
38
|
+
(subscription) => subscription.clientId != clientId
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if (!subscriptions.subscribedClients.length) {
|
|
42
|
+
itemSubscriptions.byFilter.delete(filterKey)
|
|
43
|
+
|
|
44
|
+
if (itemSubscriptions.byFilter.size == 0) {
|
|
45
|
+
this.byItem.delete(itemName)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
unsubscribeAll(clientId: string) {
|
|
51
|
+
for (const [itemName, itemSubscriptions] of this.byItem.entries()) {
|
|
52
|
+
for (const [filterKey, subscriptions] of itemSubscriptions.byFilter.entries()) {
|
|
53
|
+
subscriptions.subscribedClients = subscriptions.subscribedClients.filter(
|
|
54
|
+
(subscription) => subscription.clientId != clientId
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (!subscriptions.subscribedClients.length) {
|
|
58
|
+
itemSubscriptions.byFilter.delete(filterKey)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (itemSubscriptions.byFilter.size == 0) {
|
|
63
|
+
this.byItem.delete(itemName)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
trigger(itemName: string, triggerFilter: Record<string, unknown> = {}, suppliedData?: unknown) {
|
|
69
|
+
const itemSub = this.byItem.get(itemName)
|
|
70
|
+
if (!itemSub) return
|
|
71
|
+
|
|
72
|
+
for (const {filter: subscriptionFilter, subscribedClients} of itemSub.byFilter.values()) {
|
|
73
|
+
if (!filterContains(triggerFilter, subscriptionFilter)) continue
|
|
74
|
+
|
|
75
|
+
subscribedClients.forEach((subscribedClient) => {
|
|
76
|
+
subscribedClient.update(suppliedData)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throttled(itemName: string, f: (d: unknown) => void) {
|
|
82
|
+
const settings: ThrottleSettings<unknown> = this.itemThrottleSettings.get(itemName) || {
|
|
83
|
+
timeout: 500,
|
|
84
|
+
reducer: lastValueReducer,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!settings.timeout) return f
|
|
88
|
+
|
|
89
|
+
return throttle(f, settings.timeout, settings.reducer || lastValueReducer)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throttleItem(itemName: string, settings: ThrottleSettings<unknown>) {
|
|
93
|
+
this.itemThrottleSettings.set(itemName, settings)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private itemThrottleSettings: Map<string, ThrottleSettings<unknown>> = new Map()
|
|
97
|
+
|
|
98
|
+
private byItem: Map<string, ItemSubscription> = new Map()
|
|
99
|
+
|
|
100
|
+
// test-only
|
|
101
|
+
_allSubscriptions() {
|
|
102
|
+
const result: Array<[itemName: string, parameters: unknown[], consumer: unknown]> = []
|
|
103
|
+
|
|
104
|
+
for (const [itemName, itemSubscriptions] of this.byItem) {
|
|
105
|
+
for (const [, parameterSubscriptions] of itemSubscriptions.byFilter) {
|
|
106
|
+
for (const client of parameterSubscriptions.subscribedClients) {
|
|
107
|
+
result.push([itemName, [parameterSubscriptions.filter], client.clientId])
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return result
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type ItemSubscription = {
|
|
117
|
+
byFilter: Map<string, FilterSubscription>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type FilterSubscription = {
|
|
121
|
+
filter: Record<string, unknown>
|
|
122
|
+
subscribedClients: Array<SubscribedClient>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type SubscribedClient = {
|
|
126
|
+
clientId: string
|
|
127
|
+
update: (suppliedData?: unknown) => void
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function filterContains(
|
|
131
|
+
triggerFilter: Record<string, unknown>,
|
|
132
|
+
subscriptionFilter: Record<string, unknown>
|
|
133
|
+
): boolean {
|
|
134
|
+
if (subscriptionFilter == null) return true // subscribe to all data
|
|
135
|
+
if (triggerFilter == null) return true // all data modified
|
|
136
|
+
|
|
137
|
+
for (const key of Object.keys(subscriptionFilter)) {
|
|
138
|
+
if (triggerFilter[key] == undefined) continue
|
|
139
|
+
if (subscriptionFilter[key] == triggerFilter[key]) continue
|
|
140
|
+
|
|
141
|
+
if (Array.isArray(triggerFilter[key]) && Array.isArray(subscriptionFilter[key])) {
|
|
142
|
+
if (safeStringify(triggerFilter[key]) == safeStringify(subscriptionFilter[key])) {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getFilterKey(parameters: unknown[]) {
|
|
154
|
+
return safeStringify(parameters?.[0] ?? null)
|
|
155
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import {PublishServicesOptions, RpcServer} from "./index.js"
|
|
2
|
+
import {LocalSubscriptions} from "./LocalSubscriptions.js"
|
|
3
|
+
import http from "http"
|
|
4
|
+
import {ConnectionsServer} from "./ConnectionsServer.js"
|
|
5
|
+
import {PromiseCache} from "../utils/promises.js"
|
|
6
|
+
import {serveHttpRequest} from "./http.js"
|
|
7
|
+
import {
|
|
8
|
+
InvocationType,
|
|
9
|
+
RemoteFunction,
|
|
10
|
+
RpcConnectionContext,
|
|
11
|
+
RpcContext,
|
|
12
|
+
RpcError,
|
|
13
|
+
RpcErrors,
|
|
14
|
+
Services,
|
|
15
|
+
} from "../rpc.js"
|
|
16
|
+
import {log} from "../logger.js"
|
|
17
|
+
import {withMiddlewares} from "../utils/middleware.js"
|
|
18
|
+
import {ServicesWithTriggers, withTriggers} from "./local.js"
|
|
19
|
+
import {safeParseJson, safeStringify} from "../utils/json.js"
|
|
20
|
+
|
|
21
|
+
export class RpcServerImpl<S extends Services<S>, C extends RpcContext> implements RpcServer {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly services: S,
|
|
24
|
+
private readonly options: PublishServicesOptions<C>
|
|
25
|
+
) {
|
|
26
|
+
if ("server" in this.options) {
|
|
27
|
+
this.httpServer = this.options.server
|
|
28
|
+
} else {
|
|
29
|
+
this.httpServer = http.createServer()
|
|
30
|
+
|
|
31
|
+
// for our own server, respond 404 on unhandled URLs
|
|
32
|
+
this.httpServer.addListener("request", (req, res) => {
|
|
33
|
+
if (!req.url?.startsWith(options.path)) {
|
|
34
|
+
res.statusCode = 404
|
|
35
|
+
res.end()
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.connectionsServer = new ConnectionsServer(
|
|
42
|
+
this.httpServer,
|
|
43
|
+
{pingInterval: options.pingInterval, path: options.path},
|
|
44
|
+
(clientId) => {
|
|
45
|
+
this.localSubscriptions.unsubscribeAll(clientId)
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
this.httpServer.addListener("request", (req, res) =>
|
|
50
|
+
serveHttpRequest(
|
|
51
|
+
req,
|
|
52
|
+
res,
|
|
53
|
+
options.path,
|
|
54
|
+
{
|
|
55
|
+
call: this.call,
|
|
56
|
+
subscribe: this.subscribe,
|
|
57
|
+
unsubscribe: this.unsubscribe,
|
|
58
|
+
},
|
|
59
|
+
options.createConnectionContext
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
start() {
|
|
65
|
+
if ("server" in this.options) {
|
|
66
|
+
return Promise.resolve()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if ("port" in this.options) {
|
|
70
|
+
const {port} = this.options
|
|
71
|
+
|
|
72
|
+
return new Promise<void>((resolve, reject) => {
|
|
73
|
+
this.httpServer.on("error", (err) => {
|
|
74
|
+
reject(err)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
this.httpServer.listen(port, this.options.host, () => {
|
|
78
|
+
resolve()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async close() {
|
|
85
|
+
await this.connectionsServer.close()
|
|
86
|
+
await new Promise<void>((resolve, reject) => {
|
|
87
|
+
this.httpServer.closeIdleConnections()
|
|
88
|
+
this.httpServer.close((err) => {
|
|
89
|
+
if (err) reject(err)
|
|
90
|
+
else resolve(err)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
createServicesWithTriggers(): ServicesWithTriggers<S> {
|
|
96
|
+
return withTriggers(this.localSubscriptions, this.services)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_allSubscriptions() {
|
|
100
|
+
return this.localSubscriptions._allSubscriptions()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private readonly localSubscriptions = new LocalSubscriptions()
|
|
104
|
+
private readonly invocationCache = new PromiseCache()
|
|
105
|
+
private readonly connectionsServer: ConnectionsServer
|
|
106
|
+
readonly httpServer
|
|
107
|
+
|
|
108
|
+
private call = async (
|
|
109
|
+
connectionContext: RpcConnectionContext,
|
|
110
|
+
itemName: string,
|
|
111
|
+
parameters: unknown[]
|
|
112
|
+
): Promise<unknown> => {
|
|
113
|
+
const item = this.getRemoteFunction(itemName)
|
|
114
|
+
|
|
115
|
+
if (!item) {
|
|
116
|
+
throw new RpcError(RpcErrors.NotFound, `Item ${itemName} not found`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
return await this.invokeLocalFunction(
|
|
121
|
+
connectionContext,
|
|
122
|
+
itemName,
|
|
123
|
+
item,
|
|
124
|
+
parameters,
|
|
125
|
+
InvocationType.Call
|
|
126
|
+
)
|
|
127
|
+
} catch (e) {
|
|
128
|
+
log.error(`Cannot call item ${itemName}.`, e)
|
|
129
|
+
throw e
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private subscribe = async (
|
|
134
|
+
connectionContext: RpcConnectionContext,
|
|
135
|
+
itemName: string,
|
|
136
|
+
parameters: unknown[]
|
|
137
|
+
) => {
|
|
138
|
+
const item = this.getRemoteFunction(itemName)
|
|
139
|
+
|
|
140
|
+
if (!item) {
|
|
141
|
+
throw new RpcError(RpcErrors.NotFound, `Item ${itemName} not found`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const lastData = await this.invokeLocalFunction(
|
|
146
|
+
connectionContext,
|
|
147
|
+
itemName,
|
|
148
|
+
item,
|
|
149
|
+
parameters,
|
|
150
|
+
InvocationType.Subscribe
|
|
151
|
+
)
|
|
152
|
+
let lastDataJson = safeStringify(lastData)
|
|
153
|
+
|
|
154
|
+
const update = this.localSubscriptions.throttled(itemName, async (suppliedData?: unknown) => {
|
|
155
|
+
try {
|
|
156
|
+
const newData =
|
|
157
|
+
suppliedData !== undefined
|
|
158
|
+
? suppliedData
|
|
159
|
+
: await this.invokeLocalFunction(
|
|
160
|
+
connectionContext,
|
|
161
|
+
itemName,
|
|
162
|
+
item,
|
|
163
|
+
parameters,
|
|
164
|
+
InvocationType.Trigger
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const newDataJson = safeStringify(newData)
|
|
168
|
+
|
|
169
|
+
if (newDataJson != lastDataJson) {
|
|
170
|
+
lastDataJson = newDataJson
|
|
171
|
+
this.connectionsServer.publish(
|
|
172
|
+
connectionContext.clientId,
|
|
173
|
+
itemName,
|
|
174
|
+
parameters,
|
|
175
|
+
newData
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
} catch (e) {
|
|
179
|
+
log.error("Cannot get data for subscription", e)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
this.localSubscriptions.subscribe(connectionContext.clientId, itemName, parameters, update)
|
|
184
|
+
|
|
185
|
+
return lastData
|
|
186
|
+
} catch (e) {
|
|
187
|
+
log.error(`Failed to subscribe ${itemName}`, e)
|
|
188
|
+
throw e
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private unsubscribe = async (
|
|
193
|
+
connectionContext: RpcConnectionContext,
|
|
194
|
+
itemName: string,
|
|
195
|
+
parameters: unknown[]
|
|
196
|
+
) => {
|
|
197
|
+
try {
|
|
198
|
+
this.localSubscriptions.unsubscribe(connectionContext.clientId, itemName, parameters)
|
|
199
|
+
} catch (e) {
|
|
200
|
+
log.error(`Failed to unsubscribe ${itemName}`, e)
|
|
201
|
+
throw e
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private getRemoteFunction(
|
|
206
|
+
itemName: string,
|
|
207
|
+
root: any = this.services
|
|
208
|
+
): {function: RemoteFunction; container: any} | undefined {
|
|
209
|
+
const parts = itemName.split("/")
|
|
210
|
+
|
|
211
|
+
let item = root
|
|
212
|
+
let parent
|
|
213
|
+
|
|
214
|
+
for (const part of parts) {
|
|
215
|
+
if (!item) return undefined
|
|
216
|
+
|
|
217
|
+
parent = item
|
|
218
|
+
item = item[part]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!item) return undefined
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
function: item,
|
|
225
|
+
container: parent,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private invokeLocalFunction(
|
|
230
|
+
connectionContext: RpcConnectionContext,
|
|
231
|
+
itemName: string,
|
|
232
|
+
item: {function: RemoteFunction; container: any},
|
|
233
|
+
parameters: unknown[],
|
|
234
|
+
invocationType: InvocationType
|
|
235
|
+
): Promise<unknown> {
|
|
236
|
+
return this.invocationCache.invoke(
|
|
237
|
+
{clientId: connectionContext.clientId, itemName, parameters},
|
|
238
|
+
() => {
|
|
239
|
+
const parametersCopy: unknown[] = safeParseJson(safeStringify(parameters))
|
|
240
|
+
|
|
241
|
+
const ctx = safeParseJson(safeStringify(connectionContext)) as C
|
|
242
|
+
ctx.itemName = itemName
|
|
243
|
+
ctx.invocationType = invocationType
|
|
244
|
+
|
|
245
|
+
const invokeItem = (...params: unknown[]) => {
|
|
246
|
+
return item.function.call(item.container, ...params, ctx)
|
|
247
|
+
}
|
|
248
|
+
return withMiddlewares<C>(ctx, this.options.middleware, invokeItem, ...parametersCopy)
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as http from "http"
|
|
2
|
+
import {IncomingMessage, ServerResponse} from "http"
|
|
3
|
+
import {RpcConnectionContext} from "../rpc.js"
|
|
4
|
+
import {safeParseJson, safeStringify} from "../utils/json.js"
|
|
5
|
+
import {log} from "../logger.js"
|
|
6
|
+
|
|
7
|
+
export async function serveHttpRequest(
|
|
8
|
+
req: IncomingMessage,
|
|
9
|
+
res: ServerResponse,
|
|
10
|
+
path: string,
|
|
11
|
+
hooks: HttpServerHooks,
|
|
12
|
+
createConnectionContext: (req: IncomingMessage) => Promise<RpcConnectionContext>
|
|
13
|
+
) {
|
|
14
|
+
// if port is in options - response 404 on other URLs
|
|
15
|
+
// oherwise just handle request
|
|
16
|
+
|
|
17
|
+
if (req.url?.startsWith(path)) {
|
|
18
|
+
try {
|
|
19
|
+
const ctx = await createConnectionContext(req)
|
|
20
|
+
|
|
21
|
+
const itemName = req.url.slice(path.length + 1)
|
|
22
|
+
|
|
23
|
+
const isJson = req.headersDistinct["content-type"]?.includes("application/json") ?? false
|
|
24
|
+
const body = isJson ? safeParseJson(await readBody(req)) : []
|
|
25
|
+
|
|
26
|
+
let result: unknown
|
|
27
|
+
switch (req.method) {
|
|
28
|
+
case "POST":
|
|
29
|
+
result = await hooks.call(ctx, itemName, body)
|
|
30
|
+
break
|
|
31
|
+
case "PUT":
|
|
32
|
+
result = await hooks.subscribe(ctx, itemName, body)
|
|
33
|
+
break
|
|
34
|
+
case "PATCH":
|
|
35
|
+
result = await hooks.unsubscribe(ctx, itemName, body)
|
|
36
|
+
break
|
|
37
|
+
default:
|
|
38
|
+
res.statusCode = 404
|
|
39
|
+
res.end()
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof result == "undefined") {
|
|
44
|
+
res.statusCode = 204
|
|
45
|
+
res.end()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof result == "string") {
|
|
50
|
+
res.setHeader("Content-Type", "text/plain")
|
|
51
|
+
res.write(result)
|
|
52
|
+
res.end()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
res.setHeader("Content-Type", "application/json")
|
|
57
|
+
res.write(safeStringify(result))
|
|
58
|
+
res.end()
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
if (e.code) {
|
|
61
|
+
res.statusCode = e.code
|
|
62
|
+
res.statusMessage = e.message
|
|
63
|
+
const {code, message, stack, ...rest} = e
|
|
64
|
+
if (Object.keys(rest).length > 0) {
|
|
65
|
+
res.setHeader("Content-Type", "application/json")
|
|
66
|
+
res.write(safeStringify(rest))
|
|
67
|
+
}
|
|
68
|
+
res.end()
|
|
69
|
+
return
|
|
70
|
+
} else {
|
|
71
|
+
log.warn(`Error in ${req.url}.`, e)
|
|
72
|
+
|
|
73
|
+
res.statusCode = 500
|
|
74
|
+
res.statusMessage = e["message"] || "Internal Server Error"
|
|
75
|
+
res.end()
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readBody(req: http.IncomingMessage) {
|
|
83
|
+
return new Promise<string>((resolve, reject) => {
|
|
84
|
+
let body = ""
|
|
85
|
+
req.on("data", (chunk: Buffer) => {
|
|
86
|
+
body += chunk.toString()
|
|
87
|
+
})
|
|
88
|
+
req.on("end", () => {
|
|
89
|
+
resolve(body)
|
|
90
|
+
})
|
|
91
|
+
req.on("error", (error: any) => {
|
|
92
|
+
reject(error)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type HttpServerHooks = {
|
|
98
|
+
call(ctx: RpcConnectionContext, itemName: string, parameters: unknown[]): Promise<unknown>
|
|
99
|
+
subscribe(
|
|
100
|
+
clientId: RpcConnectionContext,
|
|
101
|
+
itemName: string,
|
|
102
|
+
parameters: unknown[]
|
|
103
|
+
): Promise<unknown>
|
|
104
|
+
unsubscribe(
|
|
105
|
+
clientId: RpcConnectionContext,
|
|
106
|
+
itemName: string,
|
|
107
|
+
parameters: unknown[]
|
|
108
|
+
): Promise<unknown>
|
|
109
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {CLIENT_ID_HEADER, RpcConnectionContext, RpcContext, Services} from "../rpc.js"
|
|
2
|
+
import {ServicesWithTriggers} from "./local.js"
|
|
3
|
+
import {Middleware} from "../utils/middleware.js"
|
|
4
|
+
import {RpcServerImpl} from "./RpcServerImpl.js"
|
|
5
|
+
import http, {IncomingMessage} from "http"
|
|
6
|
+
|
|
7
|
+
export async function publishServices<S extends Services<S>, C extends RpcContext>(
|
|
8
|
+
services: S,
|
|
9
|
+
overrideOptions: Partial<PublishServicesOptions<C>> & ({port: number} | {server: http.Server})
|
|
10
|
+
): Promise<{
|
|
11
|
+
server: RpcServer
|
|
12
|
+
services: ServicesWithTriggers<S>
|
|
13
|
+
httpServer: http.Server
|
|
14
|
+
}> {
|
|
15
|
+
const options = {
|
|
16
|
+
...defaultOptions,
|
|
17
|
+
...overrideOptions,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const rpcServer = new RpcServerImpl<S, C>(services, options)
|
|
21
|
+
|
|
22
|
+
await rpcServer.start()
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
services: rpcServer.createServicesWithTriggers(),
|
|
26
|
+
server: rpcServer,
|
|
27
|
+
httpServer: rpcServer.httpServer,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type RpcServer = {
|
|
32
|
+
close(): Promise<void>
|
|
33
|
+
// test-only
|
|
34
|
+
_allSubscriptions(): Array<any[]>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PublishServicesOptions<C extends RpcContext> = {
|
|
38
|
+
host: string
|
|
39
|
+
|
|
40
|
+
path: string
|
|
41
|
+
middleware: Middleware<C>[]
|
|
42
|
+
pingInterval: number
|
|
43
|
+
createConnectionContext(req: IncomingMessage): Promise<RpcConnectionContext>
|
|
44
|
+
} & (
|
|
45
|
+
| {
|
|
46
|
+
server: http.Server
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
port: number
|
|
50
|
+
}
|
|
51
|
+
| {}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const defaultOptions: Omit<PublishServicesOptions<RpcContext>, "port"> = {
|
|
55
|
+
path: "",
|
|
56
|
+
host: "0.0.0.0",
|
|
57
|
+
middleware: [],
|
|
58
|
+
pingInterval: 30 * 1000, // should be in-sync with client
|
|
59
|
+
|
|
60
|
+
async createConnectionContext(req: IncomingMessage): Promise<RpcConnectionContext> {
|
|
61
|
+
return {
|
|
62
|
+
clientId: req.headersDistinct[CLIENT_ID_HEADER]?.[0] || "anon",
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|