ditch scala

This commit is contained in:
Will Freeman
2026-01-01 19:04:08 -06:00
parent 8d27a8732b
commit 4b953023b3
21 changed files with 2277 additions and 375 deletions
+4
View File
@@ -0,0 +1,4 @@
# Environment variables
GITHUB_TOKEN=your_github_token_here
PORT=8080
NODE_ENV=development
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
*.log
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy built application
COPY dist ./dist
# Expose port
EXPOSE 8080
# Start server
CMD ["node", "dist/server.js"]
+94
View File
@@ -0,0 +1,94 @@
# DeFlock Backend Service
A Node.js TypeScript service providing API endpoints for the DeFlock application.
## Features
- **GitHub Sponsors**: Fetch GitHub sponsors data
- **Geocoding**: Geocode addresses using Nominatim with LRU caching to avoid rate limits
## Prerequisites
- Node.js 18+
- npm or yarn
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create a `.env` file based on `.env.example`:
```bash
cp .env.example .env
```
3. Add your GitHub token to `.env`:
```
GITHUB_TOKEN=your_github_token_here
PORT=8080
```
## Development
Start the development server with hot reload:
```bash
npm run dev
```
## Production
Build and start the production server:
```bash
npm run build
npm start
```
## API Endpoints
### Health Check
- `GET /api/healthcheck` - Returns service health status
- `HEAD /api/healthcheck` - Returns 200 OK
### GitHub Sponsors
- `GET /api/sponsors/github` - Fetches GitHub sponsors for the configured user
### Geocoding
- `GET /api/geocode?query=<address>` - Geocodes an address using Nominatim
- Uses LRU cache (max 300 entries) to minimize API calls
- Respects Nominatim rate limits
## Configuration
### Environment Variables
- `GITHUB_TOKEN` - GitHub personal access token with sponsorship read permissions
- `PORT` - Server port (default: 8080)
- `NODE_ENV` - Environment mode (development/production)
### CORS
Allowed origins are configured in [server.ts](src/server.ts):
- http://localhost:8080
- http://localhost:5173
- https://deflock.me
- https://www.deflock.me
### Caching
The geocoding service uses an LRU cache with:
- Max 300 entries
- Automatic eviction of least recently used entries
- No TTL (cache persists until server restart)
## Project Structure
```
backend/
├── src/
│ ├── server.ts # Main server file
│ └── services/
│ ├── github.ts # GitHub API client
│ └── nominatim.ts # Nominatim geocoding client with cache
├── package.json
├── tsconfig.json
└── .env.example
```
+1866
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "deflock-backend",
"version": "1.0.0",
"description": "Backend API service for DeFlock",
"main": "dist/server.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"lint": "eslint src --ext .ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"axios": "^1.6.2",
"lru-cache": "^10.1.0",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.5",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0"
}
}
+80
View File
@@ -0,0 +1,80 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { GithubClient } from './services/github';
import { NominatimClient } from './services/nominatim';
dotenv.config();
const app = express();
const port = process.env.PORT || 8080;
const githubClient = new GithubClient();
const nominatimClient = new NominatimClient();
const allowedOrigins = [
'http://localhost:8080',
'http://localhost:5173',
'https://deflock.me',
'https://www.deflock.me',
];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true
}));
app.use(express.json());
app.head('/healthcheck', (req: Request, res: Response) => {
res.status(200).end();
});
app.get('/sponsors/github', async (req: Request, res: Response) => {
try {
const sponsors = await githubClient.getSponsors('frillweeman');
res.json(sponsors);
} catch (error) {
console.error('Error fetching GitHub sponsors:', error);
res.status(500).json({ error: 'Failed to fetch GitHub sponsors' });
}
});
app.get('/geocode', async (req: Request, res: Response) => {
const query = req.query.query as string;
if (!query) {
return res.status(400).json({ error: 'Query parameter is required' });
}
try {
const results = await nominatimClient.geocodePhrase(query);
res.json(results);
} catch (error) {
console.error('Error geocoding:', error);
res.status(500).json({ error: 'Failed to geocode phrase' });
}
});
app.use((req: Request, res: Response) => {
res.status(404).json({ error: 'The requested resource could not be found.' });
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
console.log('Press Ctrl+C to stop...');
});
+75
View File
@@ -0,0 +1,75 @@
import axios from 'axios';
interface GithubSponsor {
login: string;
name: string | null;
avatarUrl: string;
url: string;
}
interface GithubSponsorsResponse {
data: {
user: {
sponsorshipsAsMaintainer: {
nodes: Array<{
sponsor: GithubSponsor;
}>;
};
};
};
}
export class GithubClient {
private readonly graphQLEndpoint = 'https://api.github.com/graphql';
private readonly githubApiToken: string;
constructor() {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error('GITHUB_TOKEN environment variable is required');
}
this.githubApiToken = token;
}
async getSponsors(username: string): Promise<GithubSponsor[]> {
const query = `
query {
user(login: "${username}") {
sponsorshipsAsMaintainer(first: 100) {
nodes {
sponsor {
login
name
avatarUrl
url
}
}
}
}
}
`;
try {
const response = await axios.post<GithubSponsorsResponse>(
this.graphQLEndpoint,
{
query,
variables: {}
},
{
headers: {
'Authorization': `Bearer ${this.githubApiToken}`,
'User-Agent': 'DeFlock Backend',
'Content-Type': 'application/json'
}
}
);
const nodes = response.data.data.user.sponsorshipsAsMaintainer.nodes;
return nodes.map(node => node.sponsor);
} catch (error) {
console.error('Failed to fetch GitHub sponsors:', error);
throw new Error('Failed to fetch GitHub sponsors');
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import axios from 'axios';
import { LRUCache } from 'lru-cache';
interface NominatimResult {
lat: string;
lon: string;
display_name: string;
geojson?: any;
[key: string]: any;
}
export class NominatimClient {
private readonly baseUrl = 'https://nominatim.openstreetmap.org/search';
private cache: LRUCache<string, NominatimResult[]>;
constructor() {
// LRU cache with max 300 entries and no TTL
// This keeps memory usage reasonable while caching frequent queries
this.cache = new LRUCache<string, NominatimResult[]>({
max: 300,
// Optional: add TTL if you want cache entries to expire
// ttl: 1000 * 60 * 60 * 24, // 24 hours
});
}
async geocodePhrase(query: string): Promise<NominatimResult[]> {
// Check cache first
const cached = this.cache.get(query);
if (cached) {
console.log(`Cache hit for: ${query}`);
return cached;
}
console.log(`Cache miss for: ${query}`);
try {
const response = await axios.get<NominatimResult[]>(this.baseUrl, {
params: {
q: query,
polygon_geojson: 1,
format: 'json'
},
headers: {
'User-Agent': 'DeFlock/1.0'
}
});
// Store in cache before returning
this.cache.set(query, response.data);
return response.data;
} catch (error) {
console.error('Failed to geocode phrase:', error);
throw new Error('Failed to geocode phrase');
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}