Skip to content

Commit 4c7b8a3

Browse files
authored
Merge pull request #862 from open-rpc/feat/allow-passing-express-app-for-integration
Allow passing existing connect app
2 parents f772779 + d7818ac commit 4c7b8a3

14 files changed

Lines changed: 739 additions & 56 deletions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,14 @@ const UdpIpcTransport = new IPCServerTranport(UDPIPCTransportOptions);
153153

154154
```
155155
import { HTTPServerTransport, HTTPSServerTransport } from "@open-rpc/server-js";
156+
import express from "express";
157+
158+
const existingApp = express();
156159
157160
const httpOptions = {
158161
middleware: [ cors({ origin: "*" }) ],
159-
port: 4345
162+
port: 4345,
163+
app: existingApp, // optional existing express/connect app
160164
};
161165
const httpsOptions = { // extends https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener
162166
middleware: [ cors({ origin: "*" }) ],

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"build": "tsc",
2121
"watch:build": "tsc --watch",
2222
"watch:test": "jest --watch",
23-
"lint": "eslint . --ext .ts"
23+
"lint": "eslint . --ext .ts",
24+
"lint:fix": "eslint . --ext .ts --fix"
2425
},
2526
"author": "",
2627
"license": "Apache-2.0",

src/server.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/* eslint-disable @typescript-eslint/no-var-requires */
2+
import Server from './server';
3+
import { ServerTransport } from './transports/server-transport';
4+
import { Router } from './router';
5+
6+
// Create test classes we'll use directly
7+
class TestRouter implements Partial<Router> {
8+
public isMethodImplemented = jest.fn();
9+
public call = jest.fn();
10+
}
11+
12+
class TestTransport extends ServerTransport {
13+
public options: any;
14+
15+
constructor(options: any = {}) {
16+
super();
17+
this.options = options;
18+
}
19+
20+
public start = jest.fn().mockResolvedValue(undefined);
21+
public stop = jest.fn().mockResolvedValue(undefined);
22+
}
23+
24+
// Create factory functions to use in tests
25+
const createTestRouter = () => new TestRouter() as unknown as Router;
26+
const createTestTransport = (options: any = {}) => new TestTransport(options);
27+
28+
// Mock MethodCallValidator
29+
jest.mock('@open-rpc/schema-utils-js', () => ({
30+
MethodCallValidator: jest.fn().mockImplementation(() => ({
31+
validate: jest.fn(),
32+
})),
33+
}));
34+
35+
describe('Server', () => {
36+
beforeEach(() => {
37+
jest.clearAllMocks();
38+
jest.spyOn(console, 'log').mockImplementation(() => { /* no-op */ });
39+
});
40+
41+
it('initializes without routers or transports when no options provided', () => {
42+
const server = new Server({ openrpcDocument: {} as any });
43+
expect((server as any).routers).toHaveLength(0);
44+
expect((server as any).transports).toHaveLength(0);
45+
});
46+
47+
it('adds router when constructed with methodMapping', () => {
48+
// Mock Router constructor
49+
const originalRouter = require('./router').Router;
50+
const mockRouter = createTestRouter();
51+
require('./router').Router = jest.fn().mockReturnValue(mockRouter);
52+
53+
const mapping = {} as any;
54+
const server = new Server({ openrpcDocument: {} as any, methodMapping: mapping });
55+
56+
// Verify Router was created with expected args
57+
expect(require('./router').Router).toHaveBeenCalledWith({} as any, mapping);
58+
expect((server as any).routers).toHaveLength(1);
59+
60+
// Restore original Router
61+
require('./router').Router = originalRouter;
62+
});
63+
64+
it('adds default transport when constructed with transportConfigs', () => {
65+
// Mock the transport factory
66+
const originalTransports = require('./transports').default;
67+
const mockTransport = createTestTransport({ port: 123 });
68+
require('./transports').default = {
69+
HTTPTransport: jest.fn().mockReturnValue(mockTransport)
70+
};
71+
72+
// Mock the Router class to prevent it from trying to use actual MethodCallValidator
73+
const originalRouter = require('./router').Router;
74+
require('./router').Router = jest.fn().mockReturnValue(createTestRouter());
75+
76+
const opts = { port: 123 } as any;
77+
const server = new Server({
78+
openrpcDocument: {} as any,
79+
methodMapping: {} as any,
80+
transportConfigs: [{ type: 'HTTPTransport', options: opts }],
81+
});
82+
83+
expect(console.log).toHaveBeenCalledWith(
84+
`Adding Transport of the type HTTPTransport on port ${opts.port}`,
85+
);
86+
87+
expect((server as any).transports).toHaveLength(1);
88+
expect((server as any).transports[0]).toBe(mockTransport);
89+
expect(mockTransport.options).toEqual(opts);
90+
91+
// Restore original modules
92+
require('./transports').default = originalTransports;
93+
require('./router').Router = originalRouter;
94+
});
95+
96+
it('throws error on invalid transport type in addDefaultTransport', () => {
97+
const server = new Server({ openrpcDocument: {} as any });
98+
expect(() => {
99+
(server as any).addDefaultTransport('InvalidTransport' as any, {} as any);
100+
}).toThrow(
101+
'The transport "InvalidTransport" is not a valid transport type.',
102+
);
103+
});
104+
105+
it('registers transport and attaches existing routers in addTransport', () => {
106+
const server = new Server({ openrpcDocument: {} as any });
107+
108+
// Create test data
109+
const router1 = createTestRouter();
110+
const router2 = createTestRouter();
111+
(server as any).routers = [router1, router2];
112+
113+
const transport = createTestTransport();
114+
jest.spyOn(transport, 'addRouter');
115+
116+
server.addTransport(transport);
117+
118+
expect(transport.addRouter).toHaveBeenCalledTimes(2);
119+
expect(transport.addRouter).toHaveBeenCalledWith(router1);
120+
expect(transport.addRouter).toHaveBeenCalledWith(router2);
121+
expect((server as any).transports).toContain(transport);
122+
});
123+
124+
it('registers router and attaches to existing transports in addRouter', () => {
125+
// Mock Router constructor
126+
const originalRouter = require('./router').Router;
127+
const mockRouter = createTestRouter();
128+
require('./router').Router = jest.fn().mockReturnValue(mockRouter);
129+
130+
const server = new Server({ openrpcDocument: {} as any });
131+
132+
// Create test data
133+
const transport = createTestTransport();
134+
jest.spyOn(transport, 'addRouter');
135+
(server as any).transports = [transport];
136+
137+
const router = server.addRouter({} as any, {} as any);
138+
139+
expect(require('./router').Router).toHaveBeenCalledWith({}, {} as any);
140+
expect(transport.addRouter).toHaveBeenCalledWith(router);
141+
expect((server as any).routers).toContain(router);
142+
143+
// Restore original Router
144+
require('./router').Router = originalRouter;
145+
});
146+
147+
it('deregisters router and detaches from transports in removeRouter', () => {
148+
const server = new Server({ openrpcDocument: {} as any });
149+
150+
// Create test data
151+
const router = createTestRouter();
152+
const transport = createTestTransport();
153+
jest.spyOn(transport, 'removeRouter');
154+
155+
(server as any).transports = [transport];
156+
(server as any).routers = [router];
157+
158+
server.removeRouter(router);
159+
160+
expect((server as any).routers).not.toContain(router);
161+
expect(transport.removeRouter).toHaveBeenCalledWith(router);
162+
});
163+
164+
it('calls start on transports in start', async () => {
165+
const server = new Server({ openrpcDocument: {} as any });
166+
167+
// Create test data
168+
const transport = createTestTransport();
169+
170+
(server as any).transports = [transport];
171+
172+
await server.start();
173+
174+
expect(transport.start).toHaveBeenCalled();
175+
});
176+
177+
it('calls stop on transports in stop', async () => {
178+
const server = new Server({ openrpcDocument: {} as any });
179+
180+
// Create test data
181+
const transport = createTestTransport();
182+
183+
(server as any).transports = [transport];
184+
185+
await server.stop();
186+
187+
expect(transport.stop).toHaveBeenCalled();
188+
});
189+
});

src/server.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,16 @@ export default class Server {
7171
this.transports.forEach((transport) => transport.removeRouter(routerToRemove));
7272
}
7373

74-
public start() {
75-
this.transports.forEach((transport) => transport.start());
74+
public async start() {
75+
for (const transport of this.transports) {
76+
await transport.start();
77+
}
78+
}
79+
80+
public async stop() {
81+
for (const transport of this.transports) {
82+
await transport.stop();
83+
}
7684
}
7785

7886
}

src/transports/http.test.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { parseOpenRPCDocument } from "@open-rpc/schema-utils-js";
33
import { Router } from "../router";
44
import HTTPTransport from "./http";
55
import { JSONRPCResponse } from "./server-transport";
6+
import connect from "connect";
7+
import http from "http";
68

79
describe("http transport", () => {
810
let transport: HTTPTransport;
@@ -17,11 +19,11 @@ describe("http transport", () => {
1719

1820
transport.addRouter(router);
1921

20-
transport.start();
22+
await transport.start();
2123
});
2224

23-
afterAll(() => {
24-
transport.stop();
25+
afterAll(async () => {
26+
await transport.stop();
2527
});
2628

2729
it("can start an http server that works", async () => {
@@ -60,4 +62,84 @@ describe("http transport", () => {
6062
const pluckedResult = result.map((r: JSONRPCResponse) => r.result);
6163
expect(pluckedResult).toEqual([4, 8]);
6264
});
65+
66+
it("allows using an existing app", async () => {
67+
const app = connect();
68+
const simpleMathExample = await parseOpenRPCDocument(examples.simpleMath);
69+
const localTransport = new HTTPTransport({ middleware: [], port: 9700, app });
70+
const router = new Router(simpleMathExample, { mockMode: true });
71+
localTransport.addRouter(router);
72+
73+
try {
74+
await localTransport.start();
75+
76+
const { result } = await fetch("http://localhost:9700", {
77+
body: JSON.stringify({
78+
id: "2",
79+
jsonrpc: "2.0",
80+
method: "addition",
81+
params: [2, 2],
82+
}),
83+
headers: { "Content-Type": "application/json" },
84+
method: "post",
85+
}).then((res) => res.json() as Promise<JSONRPCResponse>);
86+
87+
expect(result).toBe(4);
88+
} finally {
89+
await localTransport.stop();
90+
}
91+
}, 30000);
92+
93+
it("handles errors when stopping the server", async () => {
94+
const errorTransport = new HTTPTransport({
95+
middleware: [],
96+
port: 9703,
97+
});
98+
let serverInstance: any;
99+
let originalCloseFn: ((callback?: (err?: Error) => void) => http.Server) | null = null;
100+
101+
try {
102+
await errorTransport.start();
103+
serverInstance = (errorTransport as any).server;
104+
originalCloseFn = serverInstance.close.bind(serverInstance);
105+
106+
const mockError = new Error("Mock close error");
107+
serverInstance.close = (callback: (err?: Error) => void) => {
108+
callback(mockError);
109+
originalCloseFn?.((_err?: Error) => { /* an actual close attempt */ });
110+
};
111+
112+
await expect(errorTransport.stop()).rejects.toThrow("Mock close error");
113+
114+
} finally {
115+
if (serverInstance && serverInstance.listening) {
116+
// Restore original close method before attempting to stop for cleanup
117+
if (originalCloseFn) {
118+
serverInstance.close = originalCloseFn;
119+
}
120+
try {
121+
await errorTransport.stop(); // Attempt to stop for cleanup
122+
} catch (cleanupError) {
123+
// Ignore cleanup errors if the main test assertion passed/failed as expected
124+
console.warn("Error during test server cleanup (port 9703):", cleanupError);
125+
}
126+
}
127+
}
128+
});
129+
130+
it("handles errors when starting the server", async () => {
131+
const errorTransport = new HTTPTransport({
132+
middleware: [],
133+
port: 9707,
134+
});
135+
const serverInstance = (errorTransport as any).server;
136+
const originalListen = serverInstance.listen.bind(serverInstance);
137+
serverInstance.listen = (port: number, cb: (err?: Error) => void) => {
138+
cb(new Error("Mock listen error"));
139+
return serverInstance;
140+
};
141+
await expect(errorTransport.start()).rejects.toThrow("Mock listen error");
142+
serverInstance.listen = originalListen;
143+
// Do not call stop, since server never started
144+
});
63145
});

0 commit comments

Comments
 (0)