Commit work so far so I can continue from the office

This commit is contained in:
Fernando Campione
2026-01-09 08:24:08 +00:00
parent 9ba4bda355
commit 6ec66ada33
14 changed files with 812 additions and 153 deletions
+16
View File
@@ -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.
</details>
<details>
+209
View File
@@ -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`.
<details>
<summary><code>GET /api/stats</code></summary>
Returns node statistics and swarm information.
```json
{
"count": 42,
"totalUnique": 1337,
"direct": 8,
"id": "abc123...",
"screenname": "BraveElephant",
"diagnostics": {...},
"chatEnabled": true,
"mapEnabled": true,
"peers": [...]
}
```
</details>
<details>
<summary><code>POST /api/chat</code></summary>
Send P2P chat message. Rate limited: 5 messages per 5 seconds.
```json
{
"content": "Hello, world!",
"scope": "GLOBAL",
"target": null
}
```
</details>
<details>
<summary><code>GET /api/github/latest-release</code></summary>
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..."
}
```
</details>
<details>
<summary><code>GET /events</code></summary>
Server-Sent Events stream for real-time updates. Returns same data as `/api/stats`.
</details>
<details>
<summary><code>GET /js/lists.js</code></summary>
Dynamic JavaScript file containing adjectives and nouns for screenname generation.
</details>
<details>
<summary><code>GET /js/screenname.js</code></summary>
Dynamic JavaScript file with screenname generation logic for browser.
</details>
<details>
<summary><code>GET /</code></summary>
Main HTML page with server-side template rendering.
</details>
## Development
### Route Structure
Routes are modular in `src/web/routes/`:
```
src/web/routes/
├── your-new-route.js
```
### Creating Routes
<details>
<summary>1. Create module</summary>
`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 };
```
</details>
<details>
<summary>2. Register route</summary>
In `src/web/routes.js`:
```javascript
const { setupYourNewRouteRoutes } = require("./routes/your-new-route");
const yourNewRouteDeps = { identity, peerManager };
setupYourNewRouteRoutes(app, yourNewRouteDeps);
```
</details>
<details>
<summary>3. Add constants</summary>
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 };
```
</details>
### Security
<details>
<summary>Rate limiting</summary>
```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);
});
```
</details>
<details>
<summary>Input validation</summary>
```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" });
}
});
```
</details>
<details>
<summary>Error handling</summary>
```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" });
}
});
```
</details>
### Testing
```bash
curl http://localhost:3000/api/your-new-route
```
+9
View File
@@ -28,8 +28,17 @@
<script src="/js/lists.js"></script>
<script src="/js/screenname.js"></script>
<script src="/js/commands.js"></script>
<script src="/js/version-checker.js"></script>
</head>
<body>
<div id="version-notification" class="version-notification">
<div class="version-content">
<span class="version-text">New version available!</span>
<a href="#" target="_blank" class="update-link">View Release</a>
<button class="dismiss-btn" title="Dismiss">×</button>
</div>
</div>
<canvas id="network" data-visual-limit="{{VISUAL_LIMIT}}"></canvas>
<div class="container">
<div id="count" class="count" data-initial-count="{{COUNT}}">
+76
View File
@@ -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();
}
+74
View File
@@ -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;
}
}
+34 -1
View File
@@ -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,
};
+48 -152
View File
@@ -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")));
};
+61
View File
@@ -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 };
+77
View File
@@ -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 };
+25
View File
@@ -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 };
+33
View File
@@ -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 };
+20
View File
@@ -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 };
+28
View File
@@ -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 };
+102
View File
@@ -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);
})();