added arkjet :D

This commit is contained in:
2026-02-05 21:46:51 -05:00
parent 9f5ec4fff2
commit 07b3f0d741
7 changed files with 430 additions and 2 deletions

16
env.example Normal file
View File

@@ -0,0 +1,16 @@
PORT=8082
HOST=0.0.0.0
DATABASE_URL="postgresql://adm_cowch:Bluish-Tycoon4-Cuddly@localhost:5432/websocketTutorial?sslmode=require&channel_binding=require"
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=adm_cowch
DATABASE_PASSWORD=Bluish-Tycoon4-Cuddly
DATABASE_DB=websocketTutorial
# Run Arcjet in development <https://docs.arcjet.com/environment#arcjet-env>.
ARCJET_ENV=development
# Arcjet key for your site (from <https://app.arcjet.com>).
# More info: <https://docs.arcjet.com/environment#arcjet-key>.
ARCJET_KEY="ajkey_01kgraw410ey49fv7r7jzxbzcc"
ARCJET_MODE=""

256
package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@arcjet/inspect": "^1.1.0",
"@arcjet/node": "^1.1.0",
"@dotenvx/dotenvx": "^1.52.0", "@dotenvx/dotenvx": "^1.52.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"express": "^5.2.1", "express": "^5.2.1",
@@ -28,6 +30,182 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
"node_modules/@arcjet/analyze": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/analyze/-/analyze-1.1.0.tgz",
"integrity": "sha512-rbgjTjk4PvoRh8wweerD5Qr0G/xar0XWNiRTvzcmrnJofuhESLjWD87p3Y3Od307gzex8Whtu+jA0LvII1H4DA==",
"license": "Apache-2.0",
"dependencies": {
"@arcjet/analyze-wasm": "1.1.0",
"@arcjet/protocol": "1.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/analyze-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/analyze-wasm/-/analyze-wasm-1.1.0.tgz",
"integrity": "sha512-CrzSBa9rQFQRfIUofm2FuwVjlnCdWK8hQz3uxbMqYFFLeGPAQftM2noHM7P2p+2Rq6j1p6u462RIpjg2Y2rhXQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/body": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/body/-/body-1.1.0.tgz",
"integrity": "sha512-ijMcy71ezPtu9aj29Cde6McBd634ugtqD26ufF8r/fb7YH/eYsWJQS4LZBnkEdgangAfb2VO1VEUpKwGSyZmqA==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/cache": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/cache/-/cache-1.1.0.tgz",
"integrity": "sha512-TFt5PdlQVOngGTL+Q02NLGtv6Ks15m68a5Jksk9rEDM8vVzwoFE6KXawPNPm0pcwow4a/C+GcYwYprDm7MpXlw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/duration": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/duration/-/duration-1.1.0.tgz",
"integrity": "sha512-22MVyEnJgRv4RpFol6LefKAjh2ei7ekBIkawPZi+F0YNQtGPTTJP7r+gLT615oUcZFptBxiuogsXk6zhFycSsA==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/env/-/env-1.1.0.tgz",
"integrity": "sha512-q6GLj4HRBJZy12EHtqre1ihvSJMikl5jwenzIMjBkkAIuyl0bpOpMpnIuaP5bJ4dkVLTpsicIcEYn6aFGZsXOA==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/headers/-/headers-1.1.0.tgz",
"integrity": "sha512-V4PVy84gxc30g8CGZaLl//QAogdaAKJ2Sbtjccd5iUmxeUXXdqW3L0/xwm0xCebwB8rxojwhG7CzD3uv781Zdw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/inspect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/inspect/-/inspect-1.1.0.tgz",
"integrity": "sha512-yaBGbG/iwfpR/mfyiomLGzePjUr7YFzLi2qss/TjDLcX6TvQ4nW+HqOidGzAE5IVqdtl13TG9vD2f2tv9AkIog==",
"license": "Apache-2.0",
"dependencies": {
"@arcjet/protocol": "1.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/ip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/ip/-/ip-1.1.0.tgz",
"integrity": "sha512-mxOqZ/EVhO3bN7NBr36yURyFLE9KEXUzW+Pbq3U9HogqjMoNA4g3rkomVo3cNWhJcNCHNMRKplWbFsvVuyRdHQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/logger": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/logger/-/logger-1.1.0.tgz",
"integrity": "sha512-QQkDpdevbDOK8Ojy7REQap6y3J0fC1/5D/TE7hlC41Y2Gz6oyXIYzbLxEoyCYkfzMeOnMthHAXXwzI+E2gvdgg==",
"license": "Apache-2.0",
"dependencies": {
"@arcjet/sprintf": "1.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/node": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/node/-/node-1.1.0.tgz",
"integrity": "sha512-89WpKP3xwvpLuKPhN7hWMAER76EnI6f5tUydVMm+LJF1TKJcZt80L91vEO4nTQU+is28BR57P4/IF7q+voDQSQ==",
"license": "Apache-2.0",
"dependencies": {
"@arcjet/body": "1.1.0",
"@arcjet/env": "1.1.0",
"@arcjet/headers": "1.1.0",
"@arcjet/ip": "1.1.0",
"@arcjet/logger": "1.1.0",
"@arcjet/protocol": "1.1.0",
"@arcjet/transport": "1.1.0",
"arcjet": "1.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/protocol": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/protocol/-/protocol-1.1.0.tgz",
"integrity": "sha512-evjUsyOh04bQgH9lWm/xIyqF3C8XkiKvRa/QYuUvMYEpe/J1hndHaOZqzqlzQqkm44cbM5l8s+06SpHyaG/iFg==",
"license": "Apache-2.0",
"dependencies": {
"@arcjet/cache": "1.1.0",
"@bufbuild/protobuf": "2.11.0",
"@connectrpc/connect": "2.1.1",
"typeid-js": "1.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/runtime": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/runtime/-/runtime-1.1.0.tgz",
"integrity": "sha512-v/7I9n3l9ZHBWGOoSBIxhEZ5WhcLPToWWXz8HBn30H1UFzholJCSDvIKesTogSt/EmxEUVDwxnHFZxZh+EX9Zw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/sprintf": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/sprintf/-/sprintf-1.1.0.tgz",
"integrity": "sha512-YA19YrUYnEPxVPS5O41MlkynVLEtBVeu/eqrXGckQHmS7PuyNZEO2h8ZU+6G+ppua9igZUEzgYFVIh6UJRjWpQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/stable-hash": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/stable-hash/-/stable-hash-1.1.0.tgz",
"integrity": "sha512-JdeLw2r7f4sJKRi48BAypjcqgwlMf8LgcVZs3xH+CjE0N9DAxkl1AY/6Wt03WOz4S0FahC/amzCVNMJ6TlnQfA==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@arcjet/transport": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@arcjet/transport/-/transport-1.1.0.tgz",
"integrity": "sha512-nv/9HSroy4UxndxFYF39St4t8yXTTKGnaKXqPDhRGBHwPGPMLvLHxvXD4bguDWu5iMSKGazsJFJx/RD/7SwG9Q==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "2.11.0",
"@connectrpc/connect": "2.1.1",
"@connectrpc/connect-node": "2.1.1",
"@connectrpc/connect-web": "2.1.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@biomejs/biome": { "node_modules/@biomejs/biome": {
"version": "2.3.13", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz",
@@ -191,6 +369,44 @@
"node": ">=14.21.3" "node": ">=14.21.3"
} }
}, },
"node_modules/@bufbuild/protobuf": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@connectrpc/connect": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz",
"integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0"
}
},
"node_modules/@connectrpc/connect-node": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.1.1.tgz",
"integrity": "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0",
"@connectrpc/connect": "2.1.1"
}
},
"node_modules/@connectrpc/connect-web": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.1.tgz",
"integrity": "sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0",
"@connectrpc/connect": "2.1.1"
}
},
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
@@ -1215,6 +1431,24 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/arcjet": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/arcjet/-/arcjet-1.1.0.tgz",
"integrity": "sha512-YRzr8kfTLdSdp45W5Xrzf1+TMAvDYELD2D2Gg6jXgLZl0PjM/SoK3TMCTz/CMxohBx2AV17/wSM24YjcDEoHcg==",
"license": "Apache-2.0",
"dependencies": {
"@arcjet/analyze": "1.1.0",
"@arcjet/cache": "1.1.0",
"@arcjet/duration": "1.1.0",
"@arcjet/headers": "1.1.0",
"@arcjet/protocol": "1.1.0",
"@arcjet/runtime": "1.1.0",
"@arcjet/stable-hash": "1.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -3211,6 +3445,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typeid-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/typeid-js/-/typeid-js-1.2.0.tgz",
"integrity": "sha512-t76ZucAnvGC60ea/HjVsB0TSoB0cw9yjnfurUgtInXQWUI/VcrlZGpO23KN3iSe8yOGUgb1zr7W7uEzJ3hSljA==",
"license": "Apache-2.0",
"dependencies": {
"uuid": "^10.0.0"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3241,6 +3484,19 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -19,6 +19,8 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@arcjet/inspect": "^1.1.0",
"@arcjet/node": "^1.1.0",
"@dotenvx/dotenvx": "^1.52.0", "@dotenvx/dotenvx": "^1.52.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"express": "^5.2.1", "express": "^5.2.1",

View File

@@ -4,6 +4,7 @@ import http from "http";
// routes // routes
import { matchRouter } from "./routes/matches.route.js"; import { matchRouter } from "./routes/matches.route.js";
import { attachWebsocketServer } from "./ws/server.js"; import { attachWebsocketServer } from "./ws/server.js";
import { securityMiddleware } from "./utils/arkjet.js";
const PORT = process.env.PORT || 8081; const PORT = process.env.PORT || 8081;
const HOST = process.env.HOST || "0.0.0.0"; const HOST = process.env.HOST || "0.0.0.0";
@@ -18,6 +19,9 @@ app.get("/", (_, res) => {
res.send("Hello from express server!"); res.send("Hello from express server!");
}); });
// middleware
app.use(securityMiddleware());
app.use("/matches", matchRouter); app.use("/matches", matchRouter);
const { broadcastMatchCreated } = attachWebsocketServer(server); const { broadcastMatchCreated } = attachWebsocketServer(server);

57
src/utils/arkjet.js Normal file
View File

@@ -0,0 +1,57 @@
import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/node";
const arcjetKey = process.env.ARCJET_KEY;
const arcjetMODE = process.env.ARCJET_MODE === "DRY_RUN" ? "DRY_RUN" : "LIVE";
if (!arcjetKey) throw new Error("ARKJET_KEY environment variable is missing.");
export const httpArkjet = arcjetKey
? arcjet({
key: arcjetKey,
rules: [
shield({ mode: arcjetMODE }),
detectBot({
mode: arcjetMODE,
allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:PREVIEW"],
}),
slidingWindow({ mode: arcjetMODE, interval: "10s", max: 50 }),
],
})
: null;
export const wsArkjet = arcjetKey
? arcjet({
key: arcjetKey,
rules: [
shield({ mode: arcjetMODE }),
detectBot({
mode: arcjetMODE,
allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:PREVIEW"],
}),
slidingWindow({ mode: arcjetMODE, interval: "2s", max: 5 }),
],
})
: null;
export const securityMiddleware = () => {
return async (req, res, next) => {
if (!httpArkjet) return next;
try {
const decision = await httpArkjet.protect(req);
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
return res.status(429).json({ error: "Too Many request" });
}
return res.status(403).json({ error: "Forbidden" });
}
} catch (e) {
console.error("ArkJet middleware error", e);
res.status(503).json({ error: "Service Unavailable" });
}
next();
};
};

View File

@@ -1,4 +1,5 @@
import { WebSocket, WebSocketServer } from "ws"; import { WebSocket, WebSocketServer } from "ws";
import { wsArkjet } from "../utils/arkjet.js";
const sendJson = (socket, payload) => { const sendJson = (socket, payload) => {
if (socket.readyState !== WebSocket.OPEN) { if (socket.readyState !== WebSocket.OPEN) {
@@ -10,7 +11,7 @@ const sendJson = (socket, payload) => {
const broadcast = (wss, payload) => { const broadcast = (wss, payload) => {
for (const client of wss.clients) { for (const client of wss.clients) {
if (client.readyState !== WebSocket.OPEN) return; if (client.readyState !== WebSocket.OPEN) continue;
client.send(JSON.stringify(payload)); client.send(JSON.stringify(payload));
} }
@@ -23,12 +24,48 @@ export const attachWebsocketServer = (server) => {
maxPayload: 1024 * 1024, // 1mb maxPayload: 1024 * 1024, // 1mb
}); });
wss.on("connection", (socket) => { wss.on("connection", async (socket, req) => {
if (wsArkjet) {
try {
const desision = await wsArkjet.protect(req);
if (desision.isDenied) {
const code = desision.reason.isRateLimit() ? 1013 : 1008;
const reason = desision.reason.isRateLimit()
? "Rate limit exceedeed"
: "Access denied";
socket.close(code, reason);
return;
}
} catch (e) {
console.error("WS connection error", e);
socket.close(1011, "Server security error");
return;
}
}
socket.isAlive = true;
socket.on("pong", () => {
socket.isAlive = true;
});
sendJson(socket, { type: "welcome" }); sendJson(socket, { type: "welcome" });
socket.on("error", console.error); socket.on("error", console.error);
}); });
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
});
}, 30 * 1000);
wss.on("close", () => clearInterval(interval));
function broadcastMatchCreated(match) { function broadcastMatchCreated(match) {
broadcast(wss, { type: "match_created", data: match }); broadcast(wss, { type: "match_created", data: match });
} }

View File

@@ -0,0 +1,56 @@
// Import the built-in HTTP module from Node.js
// This lets us make HTTP requests without curl or extra packages
import http from "http";
// How many times we want to hit the endpoint
const TOTAL_REQUESTS = 60;
// Counter to keep track of how many requests we've made
let count = 0;
// The URL we are testing
const url = "http://localhost:8082/matches";
// test websocket rate limit
const testWS = () => {
for (let i = 0; i < 10; i++) {
const ws = new WebSocket("ws://localhost:8082/ws");
ws.onopen = () => console.info(`Socket ${i} opened`);
ws.onclose = (e) =>
console.info(`Socket ${i} closed: ${e.code} ${e.reason}`);
}
};
// This function makes ONE HTTP request
function makeRequest() {
count++;
// Create an HTTP GET request
const req = http.get(url, (res) => {
// res.statusCode is the HTTP response code (200, 404, 500, etc)
console.info(res.statusCode);
// We don't care about the response body, so just discard it
res.resume();
// If we haven't hit our limit yet, make another request
if (count < TOTAL_REQUESTS) {
makeRequest();
}
});
// If something goes wrong (server down, connection refused, etc)
req.on("error", (err) => {
console.error("Request failed:", err.message);
// Still continue so we always try 60 times
if (count < TOTAL_REQUESTS) {
makeRequest();
}
});
testWS();
}
// Start the first request
makeRequest();