Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
7213cd9
feat: Implement WebSocket provider with middleware support and protoc…
Sep 12, 2025
fe359a5
temp: update workflow to release and publish websocket-provider inste…
Sep 15, 2025
3d31022
Revert "temp: update workflow to release and publish websocket-provid…
Sep 15, 2025
64a4671
feat: Add GitHub Actions workflow for releasing and publishing websoc…
Sep 15, 2025
62657b4
fix: Update workflow trigger to push on websocket-provider branch
Sep 15, 2025
6603205
fix: Rename job from 'test' to 'setup' and update dependencies for wo…
Sep 15, 2025
42c7e43
CI: bumps websocket-provider to 1.0.1-websocket-provider.0
Sep 15, 2025
1493375
fix: Publish websocket-provider package from feature branches
Sep 15, 2025
1c36935
fix: Correct npm command in publish step of workflow
Sep 15, 2025
95ea7e8
fix: pass correct flags to npm publish command in publish-websocket w…
Sep 15, 2025
36477e8
rename to secure-ws
Sep 16, 2025
6ff441e
refactor: remove duplicate websocket-provider and related files
Sep 16, 2025
421f059
refactor: websocket-provider -> secure-ws in GitHub Actions workflow
Sep 16, 2025
0958ebb
CI: bumps secure-ws to 0.0.2-websocket-provider.0
Sep 16, 2025
4a5939f
fix: add build step before publishing secure-ws package
Sep 16, 2025
024f82a
CI: bumps secure-ws to 0.0.2-websocket-provider.1
Sep 16, 2025
f0f16c7
fix: add missing files entry in package.json
Sep 16, 2025
ee8cf47
CI: bumps secure-ws to 0.0.2-websocket-provider.2
Sep 16, 2025
f30b8c3
Temp: log dir structure after build
Sep 16, 2025
7bf514d
Revert "Temp: log dir structure after build"
Sep 16, 2025
c32a879
fix: remove build job and add it as a step in publish_and_release
Sep 16, 2025
644d138
CI: bumps secure-ws to 0.0.2-websocket-provider.3
Sep 16, 2025
568f2a9
fix: add missing types and declaration options in package.json and ts…
Sep 16, 2025
37d83d9
CI: bumps secure-ws to 0.0.2-websocket-provider.4
Sep 16, 2025
5856992
fix: update workflow trigger to use paths instead of branches for sec…
Sep 17, 2025
a5b60c5
add: README.md for secure-ws
Sep 17, 2025
d26c68f
CI: bumps secure-ws to 0.0.2-websocket-provider.5
Sep 17, 2025
c8d6dbb
fix: correct import path for WebSocketProvider in README.md
Sep 17, 2025
ec03b63
CI: bumps secure-ws to 0.0.2-websocket-provider.6
Sep 17, 2025
e84a201
fix: replace btoa and atob with Buffer for base64 encoding and decoding
Sep 17, 2025
5f60b33
fix: implement MockResponse interface and update middleware type defi…
Sep 17, 2025
b7e47d4
fix: use IncomingMessageWithBody interface in injectHttpRequest
Sep 17, 2025
cf0c8fa
fix: ensure injectedRequest is only created when ws.protocol is valid
Sep 17, 2025
f36634f
CI: bumps secure-ws to 0.0.2-websocket-provider.7
Sep 17, 2025
c306e15
fix: remove trailing comma in tsconfig.json [skip ci]
Sep 17, 2025
b083585
fix: update sendStatus method to send JSON response format
Sep 17, 2025
48ee8a7
CI: bumps secure-ws to 0.0.2-websocket-provider.8
Sep 17, 2025
ecb0ec7
fix: include response type in sendStatus method JSON response
Sep 17, 2025
9016a12
CI: bumps secure-ws to 0.0.2-websocket-provider.9
Sep 17, 2025
348cf65
fix: update send method to send JSON response format
Sep 17, 2025
05eeb34
CI: bumps secure-ws to 0.0.2-websocket-provider.10
Sep 17, 2025
3537477
fix: replace socket.write with console log for missing routes
Sep 18, 2025
6d02b62
CI: bumps secure-ws to 0.0.2-websocket-provider.11
Sep 18, 2025
e13aebc
add: unique ID to socket in handleUpgrade method
Sep 24, 2025
398e7d6
CI: bumps secure-ws to 0.0.2-websocket-provider.12
Sep 24, 2025
2ce2dd2
fix: add socket id to ws object instead of raw socket duplex object
Sep 24, 2025
5d003c0
CI: bumps secure-ws to 0.0.2-websocket-provider.13
Sep 24, 2025
fcb9da3
fix: manage client socket lifecycle by removing from clientSockets ma…
Sep 25, 2025
f03b152
CI: bumps secure-ws to 0.0.2-websocket-provider.14
Sep 25, 2025
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
89 changes: 89 additions & 0 deletions .github/workflows/npm-publish-secure-ws.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: Release and Publish secure-ws

on:
push:
paths:
- "secure-ws/**"

env:
BRANCH_TAG: "${{ github.ref_name == 'main' && 'latest' || github.ref_name }}"
BRANCH: ${{ github.ref_name }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
publish_and_release:
name: Publish secure-ws package
environment: "${{ github.ref_name == 'main' && 'Prod' || 'Dev' }}"
runs-on: ubuntu-latest
defaults:
run:
working-directory: "secure-ws"
steps:
- name: Generate token
if: ${{ github.ref_name == 'main'}}
id: generate_token
uses: tibdex/github-app-token@v1
with:
app_id: ${{ vars.FUNDABOT_APP_ID }}
private_key: ${{ secrets.FUNDABOT_PRIVATE_KEY }}

- uses: actions/setup-node@v3
with:
node-version: 18

- uses: actions/checkout@v3
with:
token: ${{ github.ref_name == 'main' && steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: Version bump
id: version
uses: phips28/gh-action-bump-version@v9.1.0
with:
major-wording: ${{ env.BRANCH == 'main' && '[bump major]' || '[bump major --force]' }}
minor-wording: ${{ env.BRANCH == 'main' && '[bump minor]' || '[bump minor --force]' }}
patch-wording: ${{ null }}
rc-wording: ${{ null }}
default: "${{ env.BRANCH == 'main' && 'patch' || 'prerelease' }}"
PACKAGEJSON_DIR: "secure-ws"
preid: "${{ env.BRANCH }}"
skip-tag: "true"
skip-push: "true"
skip-commit: "true"
bump-policy: "ignore"

- name: Commit changes
env:
VERSION: ${{ steps.version.outputs.newTag }}
run: |
git config user.email "fundabot@fundwave.com"
git config user.name "fundabot"
git commit -a -m "CI: bumps secure-ws to $VERSION" -m "[skip ci]"

- name: Publish package to npm
run: |
echo //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} > ~/.npmrc
echo registry=https://registry.npmjs.org/ >> ~/.npmrc
npm publish --tag $BRANCH_TAG --access public
env:
NODE_AUTH_TOKEN: ${{secrets.NPMJS_TOKEN}}

- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ env.GITHUB_TOKEN }}
branch: ${{ github.ref }}

- name: Release
if: ${{ github.ref_name == 'main'}}
env:
VERSION: ${{ steps.version.outputs.newTag }}
run: |
if [ "${BRANCH}" != "main" ]; then PRERELEASE="-p"; fi
echo "Releasing version ${VERSION} on branch ${BRANCH}"
gh release create ${VERSION} --target ${BRANCH} --generate-notes ${PRERELEASE}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**/dist/**
**/node_modules/**
40 changes: 22 additions & 18 deletions .talismanrc
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
threshold: medium
fileignoreconfig:
- filename: aws-credentials-utils/store-credentials.sh
checksum: 784aec6e80314be796af73887ba630fbaf0e116cc4d11bd5cba787a20f9c4bbb
- filename: jwks-slim/README.md
checksum: a1954df51e49fc6a09d7fe04bac198fe60a4fb39b2569180475a749a72df472d
- filename: jwks-slim/package-lock.json
checksum: d014d548f9f2997cbe1d7c4f7ec312f8c634e88788bc453ccc606ea70e081a77
- filename: jwks-slim/tests/cache.test.js
checksum: 716f25000789d7ca18adfe07f968bae6eb2fbfa1c3d05e9e92897b9597ac5f17
- filename: jwks-slim/tests/mock.js
checksum: 0d2fd2ec4847acda384c398e04e8aab9a615a8599b0a06d685e7fb100e71537c
- filename: jwks-slim/tests/keys.js
checksum: 207c0d07dea4c069883822b2b268d3b34c8b18bdfd645b8aa3b2be20f280c270
- filename: jwks-slim/tests/index.test.js
checksum: 992a5c1f6c254fd344ae52955aa0c1c22a59e0472fe836b0450ff499255ef6f8
- filename: aws-ssm/package-lock.json
checksum: 4cf91061e42ed9b1aafb17bdf68c9b4b24a7f86b191133ab4095fa635f8f01a0
version: ""
- filename: aws-credentials-utils/store-credentials.sh
checksum: 784aec6e80314be796af73887ba630fbaf0e116cc4d11bd5cba787a20f9c4bbb
- filename: aws-ssm/package-lock.json
checksum: 4cf91061e42ed9b1aafb17bdf68c9b4b24a7f86b191133ab4095fa635f8f01a0
- filename: jwks-slim/README.md
checksum: a1954df51e49fc6a09d7fe04bac198fe60a4fb39b2569180475a749a72df472d
- filename: jwks-slim/package-lock.json
checksum: d014d548f9f2997cbe1d7c4f7ec312f8c634e88788bc453ccc606ea70e081a77
- filename: jwks-slim/tests/cache.test.js
checksum: 716f25000789d7ca18adfe07f968bae6eb2fbfa1c3d05e9e92897b9597ac5f17
- filename: jwks-slim/tests/index.test.js
checksum: 992a5c1f6c254fd344ae52955aa0c1c22a59e0472fe836b0450ff499255ef6f8
- filename: jwks-slim/tests/keys.js
checksum: 207c0d07dea4c069883822b2b268d3b34c8b18bdfd645b8aa3b2be20f280c270
- filename: jwks-slim/tests/mock.js
checksum: 0d2fd2ec4847acda384c398e04e8aab9a615a8599b0a06d685e7fb100e71537c
- filename: secure-ws/mocks/express-response.ts
checksum: 7774e72616894119c419f7ad86a3da579036b0391fd208766bcdcb96401b8d2c
- filename: websocket-provider/package-lock.json
checksum: 605a2d92b91f08ee577d3b1e7ed38d27eef43573a044608c22466f05ab6eaff7
threshold: medium
version: "1.0"
20 changes: 20 additions & 0 deletions secure-ws/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Exclude TypeScript source files and configs
src/
*.ts
*.tsx
tsconfig.json
# Exclude node_modules
node_modules/
# Exclude test files and folders
test/
*.spec.*
*.test.*
# Exclude build scripts and configs
*.log
*.env
# Exclude editor and OS files
.DS_Store
.vscode/
.idea/
# Exclude other common files
coverage/
46 changes: 46 additions & 0 deletions secure-ws/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# secure-ws

secure-ws is a TypeScript library for secure WebSocket servers that lets you run Express-style middleware during the upgrade handshake, ensuring unauthenticated connections are never left open.

## Why use secure-ws

- Run middleware during the WebSocket upgrade handshake
- Reuse Express Middlewares for authentication, validation and more
- Abstract away connection, upgrade, and messaging with Express-style routes, middleware, and controllers.

## Installation

```
npm install --save secure-ws
```

## Usage

```typescript
import express from "express";
import { WebSocketProvider } from 'secure-ws';

const wsApp = new WebSocketProvider();
const app = express();

// app.get...
// app.post...

wsApp.addRoute(
"/extract/metrics",
{
onConnect: [authMiddleware1, authMiddleware2],
onMessage: [controller]
}
);

const httpServer = app.listen(PORT, () => {
console.info(`Service running at port: ${PORT}`);
});

httpServer.on('upgrade', wsApp.handleUpgrade);
```

## License

MIT
99 changes: 99 additions & 0 deletions secure-ws/core/web-socket-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { WebSocketServer } from 'ws';
import { IncomingMessage } from 'http';
import { decode } from "@msgpack/msgpack";
import { randomUUID } from "crypto";

import { WSProtocolCodec } from './ws-protocol-codec';
import { runExpressMiddleware } from '../utils/middleware-adapter';
import { injectHttpRequest } from '../utils/inject-http-request';

import { WSController } from '../types/ws-controller';
import { AddRouteParams } from '../types/add-route-params';
import { ClientSocket } from '../types/client-socket';
import { Duplex } from 'stream';

export class WebSocketProvider {
public server: WebSocketServer;
public clientSockets: Map<string, ClientSocket>;
private routes: Record<string, AddRouteParams>;

constructor() {
this.server = new WebSocketServer({ noServer: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if we set noServer to false? Then we are able to set our own upgrade logic in the specified http server?

https://www.reddit.com/r/node/comments/sfgmum/can_someone_kindly_explain_what_noserver_mode/

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If noServer is set to false (which it is by default), it spins up an http server on the specified port. In this case we won't have any control over the upgrade logic.

const wss = new WebSocketServer({ port: 8080 });

In our case, we already have an http server provided by express and we want to just use the socket connection after upgrade happens. Hence noServer.

The alternative is to pass the server to the constructor.

import { createServer } from 'https';
import { WebSocketServer } from 'ws';

const server = createServer({
  cert: readFileSync('/path/to/cert.pem'),
  key: readFileSync('/path/to/key.pem')
});
const wss = new WebSocketServer({ server });

But even in that case, this.server.handleUpgrade will be called automatically on all upgrades. We don't want this as we only want to upgrade if the enpoint matches a registered route [Ref lines 38-41 core/web-socket-provider.ts]

From docs:

One of port, server or noServer must be provided or an error is thrown.

Refs:

  1. https://www.npmjs.com/package/ws
  2. https://github.com/websockets/ws/blob/0a9621f9ff35e6f80c9c8471d0b202af4e357705/doc/ws.md#new-websocketserveroptions-callback

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the idea is to run the WS server in parallel with regular REST API? I'd imagine having a separate server for WS only might help plan scaling up and rate limiting better.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that depends on the number of ws endpoints one wants across services. If we want to have one endpoint for most of the services that would mean a common ws server would need access to all resources for those microservices. This ws server would become a monolith soon. For just a few endpoints, having a common ws makes sense for scalability but, in my opinion, the drawbacks would overcome the benefits as the number of endpoints increase.

The other alternative would be to have ws microservices in parallel with the existing microservices. But that would nearly double the number of microservices we have right now, leading to additional overhead of managing these services.

Another approach that was discussed previously was to have a common ws-service that only manages a single socket connection from the client and relays the request to the respective services. However it would still need ws endpoints on the services that it connects to. We can add rate limiting rules to this service and then later plug this between the client and server with the current architecture.

this.server.on('connection', this.handleConnection);
this.routes = {};
this.clientSockets = new Map();
}

public addRoute = (
path: string,
{
onConnect,
onMessage,
} : AddRouteParams
) => {
this.routes[path] = {
onConnect,
onMessage
}
}
public handleUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
const { pathname } = new URL(request.url!, 'wss://base.url');

if (this.routes[pathname]) {
this.server.handleUpgrade(request, socket, head, (ws: ClientSocket) => {

ws.id = `${Date.now()}-${randomUUID()}`;
this.clientSockets.set(ws.id, ws);
ws.on('close', () => {
this.clientSockets.delete(ws.id);
});

this.server.emit('connection', ws, request);
});
} else {
console.log('No route for path:', pathname);
socket.end();
}
};

private handleConnection = async (ws: ClientSocket, request: IncomingMessage) => {
const { pathname } = new URL(request.url!, 'wss://base.url');

let injectedRequest = {} as IncomingMessage;

if (typeof ws.protocol === 'string' && ws.protocol.trim() !== '') {
const httpRequest = WSProtocolCodec.decode(ws.protocol);
injectedRequest = injectHttpRequest(request, httpRequest);
}

if(!this.routes[pathname]) {
ws.close(1000, 'Unknown path');
return;
}

const { onConnect, onMessage } = this.routes[pathname];

for (const middleware of onConnect) {
const res = await runExpressMiddleware(middleware, injectedRequest, ws);
if (!res.success) {
console.error("Middleware rejected connection:", res.error);
ws.send(JSON.stringify({ error: res.error }));
ws.close(1000, res.error);
return;
}
}

ws.on('message', this.handleMessage(ws, onMessage));
ws.send(JSON.stringify({ type: "connection:ack" }));
};

private handleMessage = (socket: ClientSocket, onMessage: WSController[]) => {
return async (message) => {
const request = decode(message) as IncomingMessage;

for (const controller of onMessage) {
await controller(request, socket);
}
};
}
}
16 changes: 16 additions & 0 deletions secure-ws/core/ws-protocol-codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export class WSProtocolCodec {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather Base64UrlSafeCodec if it isn't specific to WS

Copy link
Author

@sudo-rgorai sudo-rgorai Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is specific to the websocket as the name of the websocket protocol (Sec-WebSocket-Protocol) needs to be url safe

static encode(data: unknown): string {
const json = JSON.stringify(data);
return Buffer.from(json).toString('base64')
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

static decode<T = unknown>(encoded: string): T {
let str = encoded.replace(/-/g, "+").replace(/_/g, "/");
while (str.length % 4) str += "=";
const json = Buffer.from(str, 'base64').toString();
return JSON.parse(json) as T;
}
}
2 changes: 2 additions & 0 deletions secure-ws/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './core/web-socket-provider';
export * from './core/ws-protocol-codec';
50 changes: 50 additions & 0 deletions secure-ws/mocks/express-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { WebSocket } from "ws";
import { MockResponse as MockResponseType } from "../types/mock-response";

export class MockResponse implements MockResponseType {
private socket: WebSocket;
public statusCode: number = 200;

constructor(socket: WebSocket) {
this.socket = socket;
}

status(code: number) {
this.statusCode = code;
return this;
}

send(data) {
const responseType = this.statusCode >= 400 ? "error" : "success";
this.socket.send(JSON.stringify({
type: responseType,
status: this.statusCode,
data: data
}));
this.socket.close();
return this;
}

sendStatus(code: number) {
this.statusCode = code;
const responseType = code >= 400 ? "error" : "success";
this.socket.send(JSON.stringify({
status: code,
statusText: this.getStatusText(),
type: responseType
}));
this.socket.close();
return this;
}

private getStatusText(): string {
const statusTexts: { [key: number]: string } = {
200: 'OK',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error'
};
return statusTexts[this.statusCode] || 'Unknown';
}
};
Loading