diff --git a/README.md b/README.md index 68001d6..481d8f0 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,22 @@ Add this to your `services.yaml`: To get the icon to work, you have to add the icon to `/app/public/icons`. See detailed [instructions](https://gethomepage.dev/configs/services/#icons). + +### API Documentation + +For developers who are not satisfied in just looking a number on the screen but are craving to add new features, a comprehensive development API documentation is available in [`devdocs/API.md`](devdocs/API.md). + +**Available Endpoints:** +- `GET /api/stats` - Current node statistics +- `POST /api/chat` - Send P2P chat messages +- `GET /api/github/latest-release` - Latest release information +- `GET /events` - Server-Sent Events stream for real-time updates +- `GET /js/lists.js` - Dynamic adjectives/nouns for screenname generation +- `GET /js/screenname.js` - Screenname generation logic +- `GET /` - Main HTML page + +The documentation includes security best practices, rate limiting guidelines, modular route structure, and examples for creating new endpoints. +
diff --git a/devdocs/API.md b/devdocs/API.md new file mode 100644 index 0000000..78e91a4 --- /dev/null +++ b/devdocs/API.md @@ -0,0 +1,209 @@ +# API Documentation + +HTTP endpoints for Hypermind integration and development. + +## Endpoints + +You can test all the available endpoints running: `nude test-api.js`. + +
+GET /api/stats + +Returns node statistics and swarm information. + +```json +{ + "count": 42, + "totalUnique": 1337, + "direct": 8, + "id": "abc123...", + "screenname": "BraveElephant", + "diagnostics": {...}, + "chatEnabled": true, + "mapEnabled": true, + "peers": [...] +} +``` + +
+ +
+POST /api/chat + +Send P2P chat message. Rate limited: 5 messages per 5 seconds. + +```json +{ + "content": "Hello, world!", + "scope": "GLOBAL", + "target": null +} +``` + +
+ +
+GET /api/github/latest-release + +Latest GitHub release information. + +```json +{ + "tag_name": "v1.2.3", + "html_url": "https://github.com/lklynet/hypermind/releases/tag/v1.2.3", + "published_at": "2026-01-08T12:00:00Z", + "body": "Release notes..." +} +``` + +
+ +
+GET /events + +Server-Sent Events stream for real-time updates. Returns same data as `/api/stats`. + +
+ +
+GET /js/lists.js + +Dynamic JavaScript file containing adjectives and nouns for screenname generation. + +
+ +
+GET /js/screenname.js + +Dynamic JavaScript file with screenname generation logic for browser. + +
+ +
+GET / + +Main HTML page with server-side template rendering. + +
+ +## Development + +### Route Structure + +Routes are modular in `src/web/routes/`: + +``` +src/web/routes/ +├── your-new-route.js +``` + +### Creating Routes + +
+1. Create module + +`src/web/routes/your-new-route.js`: + +```javascript +const setupYourNewRouteRoutes = (router, dependencies) => { + const { identity, peerManager } = dependencies; + + router.get("/api/your-new-route", (req, res) => { + res.json({ success: true }); + }); +}; + +module.exports = { setupYourNewRouteRoutes }; +``` + +
+ +
+2. Register route + +In `src/web/routes.js`: + +```javascript +const { setupYourNewRouteRoutes } = require("./routes/your-new-route"); + +const yourNewRouteDeps = { identity, peerManager }; +setupYourNewRouteRoutes(app, yourNewRouteDeps); +``` + +
+ +
+3. Add constants + +In `src/config/constants.js`: + +```javascript +const YOUR_CONFIG = { + enabled: process.env.ENABLE_YOUR_FEATURE === "true", + rateLimit: parseInt(process.env.YOUR_RATE_LIMIT) || 1000, +}; + +module.exports = { YOUR_CONFIG }; +``` + +
+ +### Security + +
+Rate limiting + +```javascript +let requestHistory = []; + +router.get("/api/your-new-route", (req, res) => { + const now = Date.now(); + requestHistory = requestHistory.filter((time) => now - time < 10000); + + if (requestHistory.length >= 5) { + return res.status(429).json({ error: "Rate limit exceeded" }); + } + + requestHistory.push(now); +}); +``` + +
+ +
+Input validation + +```javascript +router.post("/api/your-new-route", (req, res) => { + const { data } = req.body; + + if (!data || typeof data !== "string" || data.length > 1000) { + return res.status(400).json({ error: "Invalid data" }); + } +}); +``` + +
+ +
+Error handling + +```javascript +router.get("/api/your-new-route", async (req, res) => { + try { + const result = await someOperation(); + res.json({ success: true, data: result }); + } catch (error) { + console.error("Error:", error); + res.status(500).json({ error: "Operation failed" }); + } +}); +``` + +
+ +### Testing + +```bash +curl http://localhost:3000/api/your-new-route +``` diff --git a/public/index.html b/public/index.html index 869913b..78680bd 100644 --- a/public/index.html +++ b/public/index.html @@ -28,8 +28,17 @@ + +
+
+ New version available! + View Release + +
+
+
diff --git a/public/js/version-checker.js b/public/js/version-checker.js new file mode 100644 index 0000000..5595259 --- /dev/null +++ b/public/js/version-checker.js @@ -0,0 +1,76 @@ +const CACHE_KEY = "hypermind-version-check"; +const CACHE_DURATION = 86400000; +const CURRENT_VERSION = "1.0.0"; + +const checkForNewVersion = async () => { + try { + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + try { + const parsedCache = JSON.parse(cached); + const now = Date.now(); + + if (now - parsedCache.timestamp < CACHE_DURATION) { + if (parsedCache.data) { + showVersionNotification(parsedCache.data); + } + return; + } + } catch (e) { + localStorage.removeItem(CACHE_KEY); + } + } + + const response = await fetch("/api/github/latest-release"); + if (!response.ok) return; + + const release = await response.json(); + + const cacheData = { + data: release, + timestamp: Date.now(), + }; + localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)); + + showVersionNotification(release); + } catch (error) { + console.error("Error checking for new version:", error); + } +}; + +const showVersionNotification = (release) => { + if (!release || !release.tag_name) return; + + const latestVersion = release.tag_name.replace(/^v/, ""); + const currentVersion = CURRENT_VERSION; + + if (latestVersion === currentVersion) return; + + const dismissedKey = `hypermind-version-dismissed-${release.tag_name}`; + if (localStorage.getItem(dismissedKey) === "true") return; + + const banner = document.getElementById("version-notification"); + if (!banner) return; + + const versionText = banner.querySelector(".version-text"); + const updateLink = banner.querySelector(".update-link"); + const dismissBtn = banner.querySelector(".dismiss-btn"); + + versionText.textContent = `New version ${release.tag_name} available!`; + updateLink.href = release.html_url; + + dismissBtn.onclick = () => { + banner.classList.remove("active"); + localStorage.setItem(dismissedKey, "true"); + }; + + setTimeout(() => { + banner.classList.add("active"); + }, 1000); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", checkForNewVersion); +} else { + checkForNewVersion(); +} diff --git a/public/style.css b/public/style.css index 7577787..3f16d9a 100644 --- a/public/style.css +++ b/public/style.css @@ -371,3 +371,77 @@ a { color: var(--color-text-anchor-link); text-decoration: none; border-bottom: background: var(--color-modal-stat-div); color: var(--color-text-main-label); } + +.version-notification { + position: fixed; + top: -100px; + left: 50%; + transform: translateX(-50%); + background: var(--color-terminal-bg); + border: 1px solid var(--color-terminal-border); + border-top: none; + border-radius: 0 0 8px 8px; + padding: 12px 20px; + z-index: 1000; + box-shadow: 0 4px 12px var(--color-terminal-shadow); + transition: top 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); + min-width: 300px; + max-width: 90%; +} + +.version-notification.active { + top: 0; +} + +.version-content { + display: flex; + align-items: center; + gap: 15px; + font-size: 0.9rem; +} + +.version-text { + color: var(--color-terminal-text-default); + font-weight: 500; +} + +.update-link { + color: #4ade80; + text-decoration: none; + border-bottom: 1px solid #4ade80; + transition: color 0.2s, border-color 0.2s; + white-space: nowrap; +} + +.update-link:hover { + color: #22c55e; + border-color: #22c55e; +} + +.dismiss-btn { + margin-left: auto; + background: none; + border: none; + color: var(--color-modal-close-btn); + font-size: 1.5rem; + cursor: pointer; + padding: 0; + line-height: 1; + transition: color 0.2s; +} + +.dismiss-btn:hover { + color: var(--color-modal-close-btn-hover); +} + +@media (max-width: 600px) { + .version-notification { + min-width: unset; + width: 90%; + } + + .version-content { + flex-wrap: wrap; + gap: 10px; + } +} diff --git a/src/config/constants.js b/src/config/constants.js index 07fb48d..9023937 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -18,11 +18,39 @@ const PEER_TIMEOUT = parseInt(process.env.PEER_TIMEOUT) || 45000; const BROADCAST_THROTTLE = 1000; const DIAGNOSTICS_INTERVAL = 10000; const PORT = process.env.PORT || 3000; +const fs = require("fs"); +const path = require("path"); + const ENABLE_CHAT = process.env.ENABLE_CHAT === "true"; const ENABLE_MAP = process.env.ENABLE_MAP === "true"; const ENABLE_THEMES = process.env.ENABLE_THEMES !== "false"; -const CHAT_RATE_LIMIT = parseInt(process.env.CHAT_RATE_LIMIT) || 5000; const VISUAL_LIMIT = parseInt(process.env.VISUAL_LIMIT) || 500; +const CHAT_RATE_LIMIT = parseInt(process.env.CHAT_RATE_LIMIT) || 5000; + +const HTML_TEMPLATE = fs.readFileSync( + path.join(__dirname, "../../public/index.html"), + "utf-8" +); + +const ADJECTIVES = fs.readFileSync( + path.join(__dirname, "../utils/adjectives.json"), + "utf-8" +); + +const NOUNS = fs.readFileSync( + path.join(__dirname, "../utils/nouns.json"), + "utf-8" +); + +const GENERATOR_LOGIC = fs.readFileSync( + path.join(__dirname, "../utils/name-generator.js"), + "utf-8" +); + +const GITHUB_REPO = { + owner: "lklynet", + name: "hypermind", +}; module.exports = { TOPIC_NAME, @@ -44,4 +72,9 @@ module.exports = { ENABLE_THEMES, CHAT_RATE_LIMIT, VISUAL_LIMIT, + HTML_TEMPLATE, + ADJECTIVES, + NOUNS, + GENERATOR_LOGIC, + GITHUB_REPO, }; diff --git a/src/web/routes.js b/src/web/routes.js index d4493b8..0ec1ecb 100644 --- a/src/web/routes.js +++ b/src/web/routes.js @@ -1,33 +1,16 @@ const express = require("express"); -const fs = require("fs"); const path = require("path"); -const crypto = require("crypto"); -const { signMessage } = require("../core/security"); -const { - ENABLE_CHAT, - ENABLE_MAP, - ENABLE_THEMES, - CHAT_RATE_LIMIT, - VISUAL_LIMIT, -} = require("../config/constants"); -const HTML_TEMPLATE = fs.readFileSync( - path.join(__dirname, "../../public/index.html"), - "utf-8" -); - -const adjectives = fs.readFileSync( - path.join(__dirname, "../utils/adjectives.json"), - "utf-8" -); -const nouns = fs.readFileSync( - path.join(__dirname, "../utils/nouns.json"), - "utf-8" -); -const generatorLogic = fs.readFileSync( - path.join(__dirname, "../utils/name-generator.js"), - "utf-8" -); +/** + * Routes should always be imported here and created in the ./routes folder. + * Please let's all make sure we follow coding standards so things don't get too messy. + */ +const { setupStatsRoutes } = require("./routes/stats"); +const { setupChatRoutes } = require("./routes/chat"); +const { setupGitHubRoutes } = require("./routes/github"); +const { setupUtilityRoutes } = require("./routes/utility"); +const { setupPageRoutes } = require("./routes/page"); +const { setupSSERoutes } = require("./routes/sse"); const setupRoutes = ( app, @@ -39,137 +22,50 @@ const setupRoutes = ( ) => { app.use(express.json()); - app.get("/js/lists.js", (req, res) => { - res.setHeader("Content-Type", "application/javascript"); - res.send(`window.ADJECTIVES = ${adjectives}; window.NOUNS = ${nouns};`); - }); + const utilityDeps = { + adjectives: require("../config/constants").ADJECTIVES, + nouns: require("../config/constants").NOUNS, + generatorLogic: require("../config/constants").GENERATOR_LOGIC, + }; - app.get("/js/screenname.js", (req, res) => { - res.setHeader("Content-Type", "application/javascript"); - const browserLogic = generatorLogic - .replace( - 'const adjectives = require("./adjectives.json");', - "const adjectives = window.ADJECTIVES;" - ) - .replace( - 'const nouns = require("./nouns.json");', - "const nouns = window.NOUNS;" - ) - .replace( - "module.exports = { generateScreenname };", - "window.generateScreenname = generateScreenname;" - ); - res.send(browserLogic); - }); + const pageDeps = { + htmlTemplate: require("../config/constants").HTML_TEMPLATE, + identity, + peerManager, + swarm, + }; - app.get("/", (req, res) => { - const count = peerManager.size; - const directPeers = swarm.getSwarm().connections.size; - const totalUnique = peerManager.totalUniquePeers; + const sseDeps = { + identity, + peerManager, + swarm, + sseManager, + diagnostics, + }; - const html = HTML_TEMPLATE.replace(/\{\{COUNT\}\}/g, count) - .replace(/\{\{ID\}\}/g, identity.screenname || "Unknown") - .replace(/\{\{FULL_ID\}\}/g, identity.id) - .replace(/\{\{DIRECT\}\}/g, directPeers) - .replace(/\{\{TOTAL_UNIQUE\}\}/g, totalUnique) - .replace(/\{\{MAP_CLASS\}\}/g, ENABLE_MAP ? "" : "hidden") - .replace(/\{\{THEMES_CLASS\}\}/g, ENABLE_THEMES ? "" : "hidden") - .replace(/\{\{VISUAL_LIMIT\}\}/g, VISUAL_LIMIT); + const statsDeps = { + identity, + peerManager, + swarm, + diagnostics, + }; - res.send(html); - }); + const chatDeps = { + identity, + swarm, + sseManager, + }; - app.get("/events", (req, res) => { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.flushHeaders(); + const githubDeps = { + repo: require("../config/constants").GITHUB_REPO, + }; - sseManager.addClient(res); - - const data = JSON.stringify({ - count: peerManager.size, - totalUnique: peerManager.totalUniquePeers, - direct: swarm.getSwarm().connections.size, - id: identity.id, - screenname: identity.screenname, - diagnostics: diagnostics.getStats(), - chatEnabled: ENABLE_CHAT, - peers: peerManager.getPeersWithIps(), - }); - res.write(`data: ${data}\n\n`); - - req.on("close", () => { - sseManager.removeClient(res); - }); - }); - - app.get("/api/stats", (req, res) => { - res.json({ - count: peerManager.size, - totalUnique: peerManager.totalUniquePeers, - direct: swarm.getSwarm().connections.size, - id: identity.id, - screenname: identity.screenname, - diagnostics: diagnostics.getStats(), - chatEnabled: ENABLE_CHAT, - peers: peerManager.getPeersWithIps(), - }); - }); - - let chatHistory = []; - - app.post("/api/chat", (req, res) => { - if (!ENABLE_CHAT) { - return res.status(403).json({ error: "Chat disabled" }); - } - - const now = Date.now(); - chatHistory = chatHistory.filter((time) => now - time < CHAT_RATE_LIMIT); - - if (chatHistory.length >= 5) { - return res.status(429).json({ - error: `Rate limit exceeded: Max 5 messages per ${ - CHAT_RATE_LIMIT / 1000 - } seconds`, - }); - } - - chatHistory.push(now); - - const { content, scope = "GLOBAL", target } = req.body; - if (!content || typeof content !== "string" || content.length > 140) { - return res.status(400).json({ error: "Invalid content" }); - } - - if (scope !== "LOCAL" && scope !== "GLOBAL") { - return res.status(400).json({ error: "Invalid scope" }); - } - - const timestamp = Date.now(); - const idBase = identity.id + content + timestamp; - const msgId = crypto.createHash("sha256").update(idBase).digest("hex"); - - const msg = { - type: "CHAT", - id: msgId, - sender: identity.id, - content: content, - timestamp: timestamp, - scope: scope, - target: target, - hops: 0, - }; - - if (scope === "GLOBAL") { - msg.sig = signMessage(`chat:${msgId}`, identity.privateKey); - } - - swarm.broadcastChat(msg); - sseManager.broadcast(msg); - - res.json({ success: true }); - }); + setupUtilityRoutes(app, utilityDeps); + setupPageRoutes(app, pageDeps); + setupSSERoutes(app, sseDeps); + setupStatsRoutes(app, statsDeps); + setupChatRoutes(app, chatDeps); + setupGitHubRoutes(app, githubDeps); app.use(express.static(path.join(__dirname, "../../public"))); }; diff --git a/src/web/routes/chat.js b/src/web/routes/chat.js new file mode 100644 index 0000000..e2e5e38 --- /dev/null +++ b/src/web/routes/chat.js @@ -0,0 +1,61 @@ +const crypto = require("crypto"); +const { signMessage } = require("../../core/security"); +const { ENABLE_CHAT, CHAT_RATE_LIMIT } = require("../../config/constants"); + +const setupChatRoutes = (router, dependencies) => { + const { identity, swarm, sseManager } = dependencies; + let chatHistory = []; + + router.post("/api/chat", (req, res) => { + if (!ENABLE_CHAT) { + return res.status(403).json({ error: "Chat disabled" }); + } + + const now = Date.now(); + chatHistory = chatHistory.filter((time) => now - time < CHAT_RATE_LIMIT); + + if (chatHistory.length >= 5) { + return res.status(429).json({ + error: `Rate limit exceeded: Max 5 messages per ${CHAT_RATE_LIMIT / 1000 + } seconds`, + }); + } + + chatHistory.push(now); + + const { content, scope = "GLOBAL", target } = req.body; + if (!content || typeof content !== "string" || content.length > 140) { + return res.status(400).json({ error: "Invalid content" }); + } + + if (scope !== "LOCAL" && scope !== "GLOBAL") { + return res.status(400).json({ error: "Invalid scope" }); + } + + const timestamp = Date.now(); + const idBase = identity.id + content + timestamp; + const msgId = crypto.createHash("sha256").update(idBase).digest("hex"); + + const msg = { + type: "CHAT", + id: msgId, + sender: identity.id, + content: content, + timestamp: timestamp, + scope: scope, + target: target, + hops: 0, + }; + + if (scope === "GLOBAL") { + msg.sig = signMessage(`chat:${msgId}`, identity.privateKey); + } + + swarm.broadcastChat(msg); + sseManager.broadcast(msg); + + res.json({ success: true }); + }); +}; + +module.exports = { setupChatRoutes }; diff --git a/src/web/routes/github.js b/src/web/routes/github.js new file mode 100644 index 0000000..cc1db59 --- /dev/null +++ b/src/web/routes/github.js @@ -0,0 +1,77 @@ +const https = require("https"); + +const setupGitHubRoutes = (router, dependencies) => { + const { repo } = dependencies; + + router.get("/api/github/latest-release", (req, res) => { + /** + * @fccview here - just mocking the call for now + */ + return res.json({ + tag_name: "1.1.0", + html_url: `https://github.com/${repo.owner}/${repo.name}/releases/tag/v1.1.0`, + published_at: "2023-06-01T12:00:00Z", + body: "This is a test release", + }); + + const options = { + hostname: "api.github.com", + path: `/repos/${repo.owner}/${repo.name}/releases/latest`, + method: "GET", + headers: { + "User-Agent": "Hypermind-Version-Checker", + }, + }; + + const request = https.request(options, (response) => { + let data = ""; + + response.on("data", (chunk) => { + data += chunk; + }); + + response.on("end", () => { + if (response.statusCode === 404) { + return res.json({ + tag_name: null, + html_url: null, + published_at: null, + body: null, + }); + } + + if (response.statusCode !== 200) { + console.error( + "Failed to fetch latest GitHub release:", + response.statusCode + ); + return res + .status(500) + .json({ error: "Failed to fetch latest release" }); + } + + try { + const release = JSON.parse(data); + res.json({ + tag_name: release.tag_name, + html_url: release.html_url, + published_at: release.published_at, + body: release.body, + }); + } catch (error) { + console.error("Error parsing GitHub response:", error); + res.status(500).json({ error: "Failed to parse release data" }); + } + }); + }); + + request.on("error", (error) => { + console.error("Error fetching latest GitHub release:", error); + res.status(500).json({ error: "Failed to fetch latest release" }); + }); + + request.end(); + }); +}; + +module.exports = { setupGitHubRoutes }; diff --git a/src/web/routes/page.js b/src/web/routes/page.js new file mode 100644 index 0000000..dec689a --- /dev/null +++ b/src/web/routes/page.js @@ -0,0 +1,25 @@ +const { ENABLE_MAP, ENABLE_THEMES, VISUAL_LIMIT } = require("../../config/constants"); + +const setupPageRoutes = (router, dependencies) => { + const { htmlTemplate, identity, peerManager, swarm } = dependencies; + + router.get("/", (req, res) => { + const count = peerManager.size; + const directPeers = swarm.getSwarm().connections.size; + const totalUnique = peerManager.totalUniquePeers; + + const html = htmlTemplate + .replace(/\{\{COUNT\}\}/g, count) + .replace(/\{\{ID\}\}/g, identity.screenname || "Unknown") + .replace(/\{\{FULL_ID\}\}/g, identity.id) + .replace(/\{\{DIRECT\}\}/g, directPeers) + .replace(/\{\{TOTAL_UNIQUE\}\}/g, totalUnique) + .replace(/\{\{MAP_CLASS\}\}/g, ENABLE_MAP ? "" : "hidden") + .replace(/\{\{THEMES_CLASS\}\}/g, ENABLE_THEMES ? "" : "hidden") + .replace(/\{\{VISUAL_LIMIT\}\}/g, VISUAL_LIMIT); + + res.send(html); + }); +}; + +module.exports = { setupPageRoutes }; diff --git a/src/web/routes/sse.js b/src/web/routes/sse.js new file mode 100644 index 0000000..49f8eea --- /dev/null +++ b/src/web/routes/sse.js @@ -0,0 +1,33 @@ +const { ENABLE_CHAT, ENABLE_MAP } = require("../../config/constants"); + +const setupSSERoutes = (router, dependencies) => { + const { identity, peerManager, swarm, sseManager, diagnostics } = dependencies; + + router.get("/events", (req, res) => { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + sseManager.addClient(res); + + const data = JSON.stringify({ + count: peerManager.size, + totalUnique: peerManager.totalUniquePeers, + direct: swarm.getSwarm().connections.size, + id: identity.id, + screenname: identity.screenname, + diagnostics: diagnostics.getStats(), + chatEnabled: ENABLE_CHAT, + mapEnabled: ENABLE_MAP, + peers: peerManager.getPeersWithIps(), + }); + res.write(`data: ${data}\n\n`); + + req.on("close", () => { + sseManager.removeClient(res); + }); + }); +}; + +module.exports = { setupSSERoutes }; diff --git a/src/web/routes/stats.js b/src/web/routes/stats.js new file mode 100644 index 0000000..b5889a2 --- /dev/null +++ b/src/web/routes/stats.js @@ -0,0 +1,20 @@ +const { ENABLE_CHAT } = require("../../config/constants"); + +const setupStatsRoutes = (router, dependencies) => { + const { peerManager, swarm, diagnostics } = dependencies; + + router.get("/api/stats", (req, res) => { + res.json({ + count: peerManager.size, + totalUnique: peerManager.totalUniquePeers, + direct: swarm.getSwarm().connections.size, + id: dependencies.identity.id, + screenname: dependencies.identity.screenname, + diagnostics: diagnostics.getStats(), + chatEnabled: ENABLE_CHAT, + peers: peerManager.getPeersWithIps(), + }); + }); +}; + +module.exports = { setupStatsRoutes }; diff --git a/src/web/routes/utility.js b/src/web/routes/utility.js new file mode 100644 index 0000000..76a7c8c --- /dev/null +++ b/src/web/routes/utility.js @@ -0,0 +1,28 @@ +const setupUtilityRoutes = (router, dependencies) => { + const { adjectives, nouns, generatorLogic } = dependencies; + + router.get("/js/lists.js", (req, res) => { + res.setHeader("Content-Type", "application/javascript"); + res.send(`window.ADJECTIVES = ${adjectives}; window.NOUNS = ${nouns};`); + }); + + router.get("/js/screenname.js", (req, res) => { + res.setHeader("Content-Type", "application/javascript"); + const browserLogic = generatorLogic + .replace( + 'const adjectives = require("./adjectives.json");', + "const adjectives = window.ADJECTIVES;" + ) + .replace( + 'const nouns = require("./nouns.json");', + "const nouns = window.NOUNS;" + ) + .replace( + "module.exports = { generateScreenname };", + "window.generateScreenname = generateScreenname;" + ); + res.send(browserLogic); + }); +}; + +module.exports = { setupUtilityRoutes }; diff --git a/test-api.js b/test-api.js new file mode 100644 index 0000000..f9db9ec --- /dev/null +++ b/test-api.js @@ -0,0 +1,102 @@ +const http = require("http"); + +const test = (method, path, body = null, validate, timeout = 5000) => { + return new Promise((resolve) => { + const options = { + hostname: "localhost", + port: 3000, + path, + method, + headers: body ? { "Content-Type": "application/json" } : {}, + }; + + let resolved = false; + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + console.log(`✗ ${method} ${path} - Timeout`); + resolve(false); + } + }, timeout); + + const req = http.request(options, (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk; + if (path === "/events" && data.includes("data:")) { + clearTimeout(timer); + if (!resolved) { + resolved = true; + const valid = validate(data, res.headers["content-type"]); + console.log(`${valid ? "✓" : "✗"} ${method} ${path}`); + req.destroy(); + resolve(valid); + } + } + }); + res.on("end", () => { + clearTimeout(timer); + if (!resolved) { + resolved = true; + try { + const valid = validate(data, res.headers["content-type"]); + console.log(`${valid ? "✓" : "✗"} ${method} ${path}`); + resolve(valid); + } catch (e) { + console.log(`✗ ${method} ${path} - ${e.message}`); + resolve(false); + } + } + }); + }); + + req.on("error", (e) => { + clearTimeout(timer); + if (!resolved) { + resolved = true; + console.log(`✗ ${method} ${path} - ${e.message}`); + resolve(false); + } + }); + + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +}; + +(async () => { + console.log("Testing API endpoints...\n"); + + const results = await Promise.all([ + test("GET", "/api/stats", null, (data) => { + const json = JSON.parse(data); + return json.count !== undefined && json.id && json.screenname && json.diagnostics; + }), + test("GET", "/api/github/latest-release", null, (data) => { + const json = JSON.parse(data); + return json.tag_name && json.html_url; + }), + test("POST", "/api/chat", { content: "test", scope: "LOCAL" }, (data) => { + const json = JSON.parse(data); + return json.success === true; + }), + test("GET", "/events", null, (data, contentType) => { + return contentType.includes("text/event-stream") && data.includes("data:"); + }, 2000), + test("GET", "/js/lists.js", null, (data, contentType) => { + return contentType.includes("javascript") && data.includes("ADJECTIVES") && data.includes("NOUNS"); + }), + test("GET", "/js/screenname.js", null, (data, contentType) => { + return contentType.includes("javascript") && data.includes("generateScreenname"); + }), + test("GET", "/", null, (data, contentType) => { + return contentType.includes("text/html") && data.includes("Hypermind"); + }), + ]); + + const passed = results.filter(Boolean).length; + const total = results.length; + + console.log(`\n${passed}/${total} tests passed`); + process.exit(passed === total ? 0 : 1); +})();