Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,14 @@ const UdpIpcTransport = new IPCServerTranport(UDPIPCTransportOptions);

```
import { HTTPServerTransport, HTTPSServerTransport } from "@open-rpc/server-js";
import express from "express";

const existingApp = express();

const httpOptions = {
middleware: [ cors({ origin: "*" }) ],
port: 4345
port: 4345,
app: existingApp, // optional existing express/connect app
};
const httpsOptions = { // extends https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener
middleware: [ cors({ origin: "*" }) ],
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"build": "tsc",
"watch:build": "tsc --watch",
"watch:test": "jest --watch",
"lint": "eslint . --ext .ts"
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix"
},
"author": "",
"license": "Apache-2.0",
Expand Down
189 changes: 189 additions & 0 deletions src/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import Server from './server';
import { ServerTransport } from './transports/server-transport';
import { Router } from './router';

// Create test classes we'll use directly
class TestRouter implements Partial<Router> {
public isMethodImplemented = jest.fn();
public call = jest.fn();
}

class TestTransport extends ServerTransport {
public options: any;

constructor(options: any = {}) {
super();
this.options = options;
}

public start = jest.fn().mockResolvedValue(undefined);
public stop = jest.fn().mockResolvedValue(undefined);
}

// Create factory functions to use in tests
const createTestRouter = () => new TestRouter() as unknown as Router;
const createTestTransport = (options: any = {}) => new TestTransport(options);

// Mock MethodCallValidator
jest.mock('@open-rpc/schema-utils-js', () => ({
MethodCallValidator: jest.fn().mockImplementation(() => ({
validate: jest.fn(),
})),
}));

describe('Server', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'log').mockImplementation(() => { /* no-op */ });
});

it('initializes without routers or transports when no options provided', () => {
const server = new Server({ openrpcDocument: {} as any });
expect((server as any).routers).toHaveLength(0);
expect((server as any).transports).toHaveLength(0);
});

it('adds router when constructed with methodMapping', () => {
// Mock Router constructor
const originalRouter = require('./router').Router;
const mockRouter = createTestRouter();
require('./router').Router = jest.fn().mockReturnValue(mockRouter);

const mapping = {} as any;
const server = new Server({ openrpcDocument: {} as any, methodMapping: mapping });

// Verify Router was created with expected args
expect(require('./router').Router).toHaveBeenCalledWith({} as any, mapping);
expect((server as any).routers).toHaveLength(1);

// Restore original Router
require('./router').Router = originalRouter;
});

it('adds default transport when constructed with transportConfigs', () => {
// Mock the transport factory
const originalTransports = require('./transports').default;
const mockTransport = createTestTransport({ port: 123 });
require('./transports').default = {
HTTPTransport: jest.fn().mockReturnValue(mockTransport)
};

// Mock the Router class to prevent it from trying to use actual MethodCallValidator
const originalRouter = require('./router').Router;
require('./router').Router = jest.fn().mockReturnValue(createTestRouter());

const opts = { port: 123 } as any;
const server = new Server({
openrpcDocument: {} as any,
methodMapping: {} as any,
transportConfigs: [{ type: 'HTTPTransport', options: opts }],
});

expect(console.log).toHaveBeenCalledWith(
`Adding Transport of the type HTTPTransport on port ${opts.port}`,
);

expect((server as any).transports).toHaveLength(1);
expect((server as any).transports[0]).toBe(mockTransport);
expect(mockTransport.options).toEqual(opts);

// Restore original modules
require('./transports').default = originalTransports;
require('./router').Router = originalRouter;
});

it('throws error on invalid transport type in addDefaultTransport', () => {
const server = new Server({ openrpcDocument: {} as any });
expect(() => {
(server as any).addDefaultTransport('InvalidTransport' as any, {} as any);
}).toThrow(
'The transport "InvalidTransport" is not a valid transport type.',
);
});

it('registers transport and attaches existing routers in addTransport', () => {
const server = new Server({ openrpcDocument: {} as any });

// Create test data
const router1 = createTestRouter();
const router2 = createTestRouter();
(server as any).routers = [router1, router2];

const transport = createTestTransport();
jest.spyOn(transport, 'addRouter');

server.addTransport(transport);

expect(transport.addRouter).toHaveBeenCalledTimes(2);
expect(transport.addRouter).toHaveBeenCalledWith(router1);
expect(transport.addRouter).toHaveBeenCalledWith(router2);
expect((server as any).transports).toContain(transport);
});

it('registers router and attaches to existing transports in addRouter', () => {
// Mock Router constructor
const originalRouter = require('./router').Router;
const mockRouter = createTestRouter();
require('./router').Router = jest.fn().mockReturnValue(mockRouter);

const server = new Server({ openrpcDocument: {} as any });

// Create test data
const transport = createTestTransport();
jest.spyOn(transport, 'addRouter');
(server as any).transports = [transport];

const router = server.addRouter({} as any, {} as any);

expect(require('./router').Router).toHaveBeenCalledWith({}, {} as any);
expect(transport.addRouter).toHaveBeenCalledWith(router);
expect((server as any).routers).toContain(router);

// Restore original Router
require('./router').Router = originalRouter;
});

it('deregisters router and detaches from transports in removeRouter', () => {
const server = new Server({ openrpcDocument: {} as any });

// Create test data
const router = createTestRouter();
const transport = createTestTransport();
jest.spyOn(transport, 'removeRouter');

(server as any).transports = [transport];
(server as any).routers = [router];

server.removeRouter(router);

expect((server as any).routers).not.toContain(router);
expect(transport.removeRouter).toHaveBeenCalledWith(router);
});

it('calls start on transports in start', async () => {
const server = new Server({ openrpcDocument: {} as any });

// Create test data
const transport = createTestTransport();

(server as any).transports = [transport];

await server.start();

expect(transport.start).toHaveBeenCalled();
});

it('calls stop on transports in stop', async () => {
const server = new Server({ openrpcDocument: {} as any });

// Create test data
const transport = createTestTransport();

(server as any).transports = [transport];

await server.stop();

expect(transport.stop).toHaveBeenCalled();
});
});
12 changes: 10 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,16 @@ export default class Server {
this.transports.forEach((transport) => transport.removeRouter(routerToRemove));
}

public start() {
this.transports.forEach((transport) => transport.start());
public async start() {
for (const transport of this.transports) {
await transport.start();
}
}

public async stop() {
for (const transport of this.transports) {
await transport.stop();
}
}

}
88 changes: 85 additions & 3 deletions src/transports/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { parseOpenRPCDocument } from "@open-rpc/schema-utils-js";
import { Router } from "../router";
import HTTPTransport from "./http";
import { JSONRPCResponse } from "./server-transport";
import connect from "connect";
import http from "http";

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

transport.addRouter(router);

transport.start();
await transport.start();
});

afterAll(() => {
transport.stop();
afterAll(async () => {
await transport.stop();
});

it("can start an http server that works", async () => {
Expand Down Expand Up @@ -60,4 +62,84 @@ describe("http transport", () => {
const pluckedResult = result.map((r: JSONRPCResponse) => r.result);
expect(pluckedResult).toEqual([4, 8]);
});

it("allows using an existing app", async () => {
const app = connect();
const simpleMathExample = await parseOpenRPCDocument(examples.simpleMath);
const localTransport = new HTTPTransport({ middleware: [], port: 9700, app });
const router = new Router(simpleMathExample, { mockMode: true });
localTransport.addRouter(router);

try {
await localTransport.start();

const { result } = await fetch("http://localhost:9700", {
body: JSON.stringify({
id: "2",
jsonrpc: "2.0",
method: "addition",
params: [2, 2],
}),
headers: { "Content-Type": "application/json" },
method: "post",
}).then((res) => res.json() as Promise<JSONRPCResponse>);

expect(result).toBe(4);
} finally {
await localTransport.stop();
}
}, 30000);

it("handles errors when stopping the server", async () => {
const errorTransport = new HTTPTransport({
middleware: [],
port: 9703,
});
let serverInstance: any;
let originalCloseFn: ((callback?: (err?: Error) => void) => http.Server) | null = null;

try {
await errorTransport.start();
serverInstance = (errorTransport as any).server;
originalCloseFn = serverInstance.close.bind(serverInstance);

const mockError = new Error("Mock close error");
serverInstance.close = (callback: (err?: Error) => void) => {
callback(mockError);
originalCloseFn?.((_err?: Error) => { /* an actual close attempt */ });
};

await expect(errorTransport.stop()).rejects.toThrow("Mock close error");

} finally {
if (serverInstance && serverInstance.listening) {
// Restore original close method before attempting to stop for cleanup
if (originalCloseFn) {
serverInstance.close = originalCloseFn;
}
try {
await errorTransport.stop(); // Attempt to stop for cleanup
} catch (cleanupError) {
// Ignore cleanup errors if the main test assertion passed/failed as expected
console.warn("Error during test server cleanup (port 9703):", cleanupError);
}
}
}
});

it("handles errors when starting the server", async () => {
const errorTransport = new HTTPTransport({
middleware: [],
port: 9707,
});
const serverInstance = (errorTransport as any).server;
const originalListen = serverInstance.listen.bind(serverInstance);
serverInstance.listen = (port: number, cb: (err?: Error) => void) => {
cb(new Error("Mock listen error"));
return serverInstance;
};
await expect(errorTransport.start()).rejects.toThrow("Mock listen error");
serverInstance.listen = originalListen;
// Do not call stop, since server never started
});
});
Loading
Loading