@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.
Files changed (97) hide show
  1. package/.prettierrc.json +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +26 -0
  4. package/dist/client/HttpClient.d.ts +10 -0
  5. package/dist/client/HttpClient.js +67 -0
  6. package/dist/client/HttpClient.js.map +1 -0
  7. package/dist/client/RemoteSubscriptions.d.ts +14 -0
  8. package/dist/client/RemoteSubscriptions.js +84 -0
  9. package/dist/client/RemoteSubscriptions.js.map +1 -0
  10. package/dist/client/RpcClientImpl.d.ts +22 -0
  11. package/dist/client/RpcClientImpl.js +96 -0
  12. package/dist/client/RpcClientImpl.js.map +1 -0
  13. package/dist/client/WebSocketConnection.d.ts +33 -0
  14. package/dist/client/WebSocketConnection.js +152 -0
  15. package/dist/client/WebSocketConnection.js.map +1 -0
  16. package/dist/client/index.d.ts +22 -0
  17. package/dist/client/index.js +28 -0
  18. package/dist/client/index.js.map +1 -0
  19. package/dist/client/remote.d.ts +14 -0
  20. package/dist/client/remote.js +66 -0
  21. package/dist/client/remote.js.map +1 -0
  22. package/dist/index.d.ts +12 -0
  23. package/dist/index.js +20 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/logger.d.ts +8 -0
  26. package/dist/logger.js +9 -0
  27. package/dist/logger.js.map +1 -0
  28. package/dist/rpc.d.ts +36 -0
  29. package/dist/rpc.js +32 -0
  30. package/dist/rpc.js.map +1 -0
  31. package/dist/server/ConnectionsServer.d.ts +13 -0
  32. package/dist/server/ConnectionsServer.js +60 -0
  33. package/dist/server/ConnectionsServer.js.map +1 -0
  34. package/dist/server/LocalSubscriptions.d.ts +12 -0
  35. package/dist/server/LocalSubscriptions.js +113 -0
  36. package/dist/server/LocalSubscriptions.js.map +1 -0
  37. package/dist/server/RpcServerImpl.d.ts +23 -0
  38. package/dist/server/RpcServerImpl.js +164 -0
  39. package/dist/server/RpcServerImpl.js.map +1 -0
  40. package/dist/server/http.d.ts +9 -0
  41. package/dist/server/http.js +83 -0
  42. package/dist/server/http.js.map +1 -0
  43. package/dist/server/index.d.ts +29 -0
  44. package/dist/server/index.js +31 -0
  45. package/dist/server/index.js.map +1 -0
  46. package/dist/server/local.d.ts +15 -0
  47. package/dist/server/local.js +46 -0
  48. package/dist/server/local.js.map +1 -0
  49. package/dist/utils/json.d.ts +2 -0
  50. package/dist/utils/json.js +34 -0
  51. package/dist/utils/json.js.map +1 -0
  52. package/dist/utils/middleware.d.ts +2 -0
  53. package/dist/utils/middleware.js +31 -0
  54. package/dist/utils/middleware.js.map +1 -0
  55. package/dist/utils/promises.d.ts +5 -0
  56. package/dist/utils/promises.js +29 -0
  57. package/dist/utils/promises.js.map +1 -0
  58. package/dist/utils/throttle.d.ts +4 -0
  59. package/dist/utils/throttle.js +40 -0
  60. package/dist/utils/throttle.js.map +1 -0
  61. package/dist/utils/types.d.ts +1 -0
  62. package/dist/utils/types.js +3 -0
  63. package/dist/utils/types.js.map +1 -0
  64. package/example/api.ts +15 -0
  65. package/example/client.ts +16 -0
  66. package/example/server.ts +37 -0
  67. package/package.json +34 -0
  68. package/src/client/HttpClient.ts +80 -0
  69. package/src/client/RemoteSubscriptions.ts +121 -0
  70. package/src/client/RpcClientImpl.ts +177 -0
  71. package/src/client/WebSocketConnection.ts +183 -0
  72. package/src/client/index.ts +56 -0
  73. package/src/client/remote.ts +118 -0
  74. package/src/index.ts +18 -0
  75. package/src/logger.ts +12 -0
  76. package/src/rpc.ts +51 -0
  77. package/src/server/ConnectionsServer.ts +78 -0
  78. package/src/server/LocalSubscriptions.ts +155 -0
  79. package/src/server/RpcServerImpl.ts +252 -0
  80. package/src/server/http.ts +109 -0
  81. package/src/server/index.ts +65 -0
  82. package/src/server/local.ts +80 -0
  83. package/src/utils/json.ts +32 -0
  84. package/src/utils/middleware.ts +38 -0
  85. package/src/utils/promises.ts +25 -0
  86. package/src/utils/throttle.ts +48 -0
  87. package/src/utils/types.ts +1 -0
  88. package/tests/calls.ts +215 -0
  89. package/tests/connection.ts +107 -0
  90. package/tests/context.ts +176 -0
  91. package/tests/middleware.ts +112 -0
  92. package/tests/misc.ts +187 -0
  93. package/tests/subscriptions.ts +442 -0
  94. package/tests/testUtils.ts +52 -0
  95. package/tests/triggers.ts +138 -0
  96. package/tsconfig.cjs.json +20 -0
  97. 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
+ }