diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e38fc41 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +PORT=3000 +PUBLIC_KEY=a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..218e3ce --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,23 @@ +name: Build +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - run: bun install + - run: bun test + - run: bun run build + - uses: softprops/action-gh-release@v1 + with: + files: | + substreams-service-websockets \ No newline at end of file diff --git a/.github/workflows/gchr.yml b/.github/workflows/gchr.yml new file mode 100644 index 0000000..660cf83 --- /dev/null +++ b/.github/workflows/gchr.yml @@ -0,0 +1,42 @@ +name: GitHub Container Registry +on: + release: + types: [ published ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{raw}} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4888387 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,14 @@ +name: Test + +on: push + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.0.0" + - run: bun install + - run: bun test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3d75225..8218469 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.env +package-lock.json +substreams-service-websockets + # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore # Logs diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4de3bb4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM oven/bun + +WORKDIR /app + +EXPOSE 3000 + +ENV PUBLIC_KEY $PUBLIC_KEY +ENV PORT $PORT + +COPY bun.lockb ./ +COPY package*.json ./ + +RUN bun install + +COPY . . + +CMD [ "bun", "http.ts" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b890295 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Pinax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 4f75172..ba759b7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,46 @@ -# websockets +# [`Substreams`](https://substreams.streamingfast.io/) Sink WebSockets -To install dependencies: +> `substreams-sink-websockets` is a tool that allows developers to pipe data extracted from a blockchain to WebSockets. + +## Quickstart ```bash -bun install +$ bun install +$ bun run dev ``` -To run: +## 📖 References -```bash -bun run index.ts +- [**Substreams** documentation](https://substreams.streamingfast.io/) +- [Bun WebSockets](https://bun.sh/docs/api/websockets) + + +## `.env` Environment variables + +```env +PORT=3000 +PUBLIC_KEY=... +``` + +## Help + +``` +$ substreams-sink-websockets --help + +Usage: substreams-sink-websockets run [options] + +Substreams Sink WebSockets +... ``` -This project was created using `bun init` in bun v1.0.1. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. +## Features + +- [x] Accept Substreams Webhook message `POST /` +- [ ] Client connect to WebSocket service +- [x] Verify tweetnacl Substreams Webhook message +- [ ] Send WebSocket messages +- [ ] Unit testing +- [ ] Prometheus Metrics `GET /metrics` +- [ ] Health check `GET /health` +- [x] Banner `GET /` +- [ ] Commander CLI \ No newline at end of file diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index 7fe9736..711f073 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4cd5f71 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +--- +version: "3" +services: + bun: + build: + dockerfile: Dockerfile + ports: + - 3000:3000 + network_mode: host + environment: + - PUBLIC_KEY=${PUBLIC_KEY:-a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9} + - PORT=${PORT:-3000} \ No newline at end of file diff --git a/index.ts b/index.ts index 956ebf9..f92e6e7 100644 --- a/index.ts +++ b/index.ts @@ -1,20 +1,55 @@ +import { Server } from "bun"; +import { PORT, PUBLIC_KEY } from "./src/config.js"; +import { verify } from "./src/verify.js"; +import { banner } from "./src/banner.js"; + const server = Bun.serve({ - fetch(req, server) { - // upgrade the request to a WebSocket - if (server.upgrade(req)) { - return; // do not return a Response - } - return new Response("Upgrade failed :(", { status: 500 }); - }, - websocket: { - open(ws) { - console.log("A new client connected!"); - }, - message(ws, message) { - console.log(message); - ws.send("Hello from the server!"); - } - }, // handlers - }); + port: PORT, + async fetch(req: Request, server: Server) { + if ( req.method == "GET" ) { + const { pathname } = new URL(req.url); + if ( pathname == "/health") return new Response("OK"); + if ( pathname == "/metrics") return new Response("TO-DO Prometheus metrics"); + if ( pathname == "/") return new Response(banner()) + return new Response("Not found", { status: 404 }); + } + + // get headers and body from POST request + if ( req.method == "POST") { + const timestamp = req.headers.get("x-signature-timestamp"); + const signature = req.headers.get("x-signature-ed25519"); + const body = await req.text(); + + if (!timestamp) return new Response("missing required timestamp in headers", { status: 400 }); + if (!signature) return new Response("missing required signature in headers", { status: 400 }); + if (!body) return new Response("missing body", { status: 400 }); + + const isVerified = await verify(body, timestamp, signature, PUBLIC_KEY); + if (!isVerified) return new Response("invalid request signature", { status: 401 }); - console.log(`Listening on http://localhost:${server.port} ...`); \ No newline at end of file + server.publish("message", body); + + // server.upgrade(req, { + // data: { + // body + // }, + // }) + + // if (server.upgrade(req)) { + // console.log("makes websocket") + // return; // do not return a Response + // } + } + return new Response("Invalid request", { status: 400 }); + }, + websocket: { + open(ws) { + console.log(`${ws.remoteAddress} client connected`); + }, + message(ws, message) { + // console.log(ws.data.body) + // ws.send(ws.data.body); + }, + }, +}); +export default server; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 556e877..0000000 --- a/package-lock.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "websockets", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "websockets", - "devDependencies": { - "bun-types": "latest" - }, - "peerDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/bun-types": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.0.1.tgz", - "integrity": "sha512-7NrXqhMIaNKmWn2dSWEQ50znMZqrN/5Z0NBMXvQTRu/+Y1CvoXRznFy0pnqLe024CeZgVdXoEpARNO1JZLAPGw==", - "dev": true - }, - "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - } -} diff --git a/package.json b/package.json index d97954e..70d34f1 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,21 @@ { - "name": "websockets", - "module": "index.ts", + "private": true, + "version": "0.1.0", + "description": "Substreams Service Websockets", + "name": "substreams-service-websockets", "type": "module", "scripts": { - "start": "bun run index.ts" + "start": "bun run index.ts", + "test": "bun test", + "build": "bun build --compile ./index.ts --outfile substreams-service-websockets", + "dev": "bun run --watch index.ts" }, - "devDependencies": { - "bun-types": "latest" + "dependencies": { + "dotenv": "latest", + "tweetnacl": "latest" }, - "peerDependencies": { - "typescript": "^5.0.0" + "devDependencies": { + "bun-types": "latest", + "typescript": "latest" } } \ No newline at end of file diff --git a/src/banner.ts b/src/banner.ts new file mode 100644 index 0000000..bf21e93 --- /dev/null +++ b/src/banner.ts @@ -0,0 +1,18 @@ +import pkg from "../package.json" assert { type: "json" }; + +// https://fsymbols.com/generators/carty/ +export function banner() { + let text =` + + ░██╗░░░░░░░██╗███████╗██████╗░░██████╗░█████╗░░█████╗░██╗░░██╗███████╗████████╗░██████╗ + ░██║░░██╗░░██║██╔════╝██╔══██╗██╔════╝██╔══██╗██╔══██╗██║░██╔╝██╔════╝╚══██╔══╝██╔════╝ + ░╚██╗████╗██╔╝█████╗░░██████╦╝╚█████╗░██║░░██║██║░░╚═╝█████═╝░█████╗░░░░░██║░░░╚█████╗░ + ░░████╔═████║░██╔══╝░░██╔══██╗░╚═══██╗██║░░██║██║░░██╗██╔═██╗░██╔══╝░░░░░██║░░░░╚═══██╗ + ░░╚██╔╝░╚██╔╝░███████╗██████╦╝██████╔╝╚█████╔╝╚█████╔╝██║░╚██╗███████╗░░░██║░░░██████╔╝ + ░░░╚═╝░░░╚═╝░░╚══════╝╚═════╝░╚═════╝░░╚════╝░░╚════╝░╚═╝░░╚═╝╚══════╝░░░╚═╝░░░╚═════╝░ + +` + text += ` 🚀 ${pkg.description} v${pkg.version}\n` + + return text; +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..466c652 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,5 @@ +import "dotenv/config"; + +if (!process.env.PUBLIC_KEY) throw new Error("PUBLIC_KEY is required"); +export const PUBLIC_KEY = process.env.PUBLIC_KEY; +export const PORT = parseInt(process.env.PORT || "3000"); \ No newline at end of file diff --git a/src/verify.spec.ts b/src/verify.spec.ts new file mode 100644 index 0000000..f355b43 --- /dev/null +++ b/src/verify.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test"; + +test("verify", async () => { + const body = ""; + const timestamp = ""; + const signature = ""; + // TO-DO + expect(true).toBeTruthy(); +}); diff --git a/src/verify.ts b/src/verify.ts new file mode 100644 index 0000000..44ba3c1 --- /dev/null +++ b/src/verify.ts @@ -0,0 +1,10 @@ +import nacl from "tweetnacl"; + +// validate signature using public key +export async function verify(body: string, timestamp: string, signature: string, PUBLIC_KEY: string){ + return nacl.sign.detached.verify( + Buffer.from(timestamp + body), + Buffer.from(signature, 'hex'), + Buffer.from(PUBLIC_KEY, 'hex') + ) +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1449bc3..9522ca6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,17 @@ { "compilerOptions": { - "lib": ["ESNext"], - "module": "esnext", - "target": "esnext", - "moduleResolution": "bundler", - "moduleDetection": "force", - "allowImportingTsExtensions": true, - "noEmit": true, - "composite": true, - "strict": true, - "downlevelIteration": true, - "skipLibCheck": true, - "jsx": "preserve", - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "allowJs": true, - "types": [ - "bun-types" // add Bun global - ] - } -} + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "types": ["bun-types"] + }, + "include": [ + "index.ts", + "src/**/*" + ] +} \ No newline at end of file