A simple AtProto app to read pet.mewsse.link records on my PDS.

Add basic http server and router

Mewsse d6ee2bc3 9f78dde7

+6 -1
package-lock.json
···
"@atcute/cbor": "^2.2.6",
"@atcute/client": "^4.0.4",
"@atcute/identity-resolver": "^1.1.4",
-
"@atcute/lex-cli": "^2.2.2",
"@atcute/lexicons": "^1.2.2",
"@skyware/jetstream": "^0.2.5",
"better-sqlite3": "^12.4.1",
···
"kysely": "^0.28.7"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.7.1"
}
···
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@atcute/lex-cli/-/lex-cli-2.2.2.tgz",
"integrity": "sha512-5hScXu4i01WNLkmMmLtQgyOBwZh9M4nijhJ9BZExA+d33/rGlJ4Us1oclw/rbEWPAjqkhA38t30KGvOfKr3chw==",
"license": "0BSD",
"dependencies": {
"@atcute/lexicon-doc": "^1.1.2",
···
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@atcute/lexicon-doc/-/lexicon-doc-1.1.2.tgz",
"integrity": "sha512-Q3ONR2635MTVWT5Fi01FFcYTfciav0ATnX5ZBon7160hiDyk4n1a9dl8dQYgx+st2/IB0ZCNvOMHPCMZacdktg==",
"license": "0BSD",
"dependencies": {
"@badrap/valita": "^0.4.6"
···
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@externdefs/collider/-/collider-0.3.0.tgz",
"integrity": "sha512-x5CpeZ4c8n+1wMFthUMWSQKqCGcQo52/Qbda5ES+JFRRg/D8Ep6/JOvUUq5HExFuv/wW+6UYG2U/mXzw0IAd8Q==",
"license": "MIT",
"peerDependencies": {
"@badrap/valita": "^0.4.4"
···
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/prebuild-install": {
···
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
···
"@atcute/cbor": "^2.2.6",
"@atcute/client": "^4.0.4",
"@atcute/identity-resolver": "^1.1.4",
"@atcute/lexicons": "^1.2.2",
"@skyware/jetstream": "^0.2.5",
"better-sqlite3": "^12.4.1",
···
"kysely": "^0.28.7"
},
"devDependencies": {
+
"@atcute/lex-cli": "^2.2.2",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.7.1"
}
···
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@atcute/lex-cli/-/lex-cli-2.2.2.tgz",
"integrity": "sha512-5hScXu4i01WNLkmMmLtQgyOBwZh9M4nijhJ9BZExA+d33/rGlJ4Us1oclw/rbEWPAjqkhA38t30KGvOfKr3chw==",
+
"dev": true,
"license": "0BSD",
"dependencies": {
"@atcute/lexicon-doc": "^1.1.2",
···
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@atcute/lexicon-doc/-/lexicon-doc-1.1.2.tgz",
"integrity": "sha512-Q3ONR2635MTVWT5Fi01FFcYTfciav0ATnX5ZBon7160hiDyk4n1a9dl8dQYgx+st2/IB0ZCNvOMHPCMZacdktg==",
+
"dev": true,
"license": "0BSD",
"dependencies": {
"@badrap/valita": "^0.4.6"
···
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@externdefs/collider/-/collider-0.3.0.tgz",
"integrity": "sha512-x5CpeZ4c8n+1wMFthUMWSQKqCGcQo52/Qbda5ES+JFRRg/D8Ep6/JOvUUq5HExFuv/wW+6UYG2U/mXzw0IAd8Q==",
+
"dev": true,
"license": "MIT",
"peerDependencies": {
"@badrap/valita": "^0.4.4"
···
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+
"dev": true,
"license": "ISC"
},
"node_modules/prebuild-install": {
···
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
+3 -2
package.json
···
"main": "src/index.ts",
"type": "module",
"scripts": {
-
"start": "node src/index.ts"
},
"author": "Mewsse",
"license": "WTFPL",
···
"@atcute/cbor": "^2.2.6",
"@atcute/client": "^4.0.4",
"@atcute/identity-resolver": "^1.1.4",
-
"@atcute/lex-cli": "^2.2.2",
"@atcute/lexicons": "^1.2.2",
"@skyware/jetstream": "^0.2.5",
"better-sqlite3": "^12.4.1",
···
"kysely": "^0.28.7"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.7.1"
}
···
"main": "src/index.ts",
"type": "module",
"scripts": {
+
"start": "node src/index.ts",
+
"lexgen": "lex-cli generate -c lex.config.js"
},
"author": "Mewsse",
"license": "WTFPL",
···
"@atcute/cbor": "^2.2.6",
"@atcute/client": "^4.0.4",
"@atcute/identity-resolver": "^1.1.4",
"@atcute/lexicons": "^1.2.2",
"@skyware/jetstream": "^0.2.5",
"better-sqlite3": "^12.4.1",
···
"kysely": "^0.28.7"
},
"devDependencies": {
+
"@atcute/lex-cli": "^2.2.2",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.7.1"
}
+24 -9
src/index.ts
···
import type { Database } from './db.ts'
import { Jetstream } from '@skyware/jetstream'
import { logger } from './lib/logger.ts'
dotenv.config()
export type Context = {
db: Database
-
jetstream: Jetstream
}
export class Server {
···
}
static async create() {
-
const db = createDb(process.env.DB_PATH || ":memory")
await migrateToLatest(db)
-
const ingester = createIngester(db)
await ingester.backfill()
const jetstream = await ingester.jetstream()
-
const ctx: Context= {
-
db,
-
jetstream
-
}
-
jetstream.start()
logger.info("Starting jetstream client")
return new Server(ctx)
}
async close() {
logger.info("Stopping jetstream client")
await this.ctx.jetstream.close()
return new Promise<void>((resolve) => {
resolve()
···
const onClose = async () => {
setTimeout(() => process.exit(1), 10000).unref()
-
await server.close()
process.exit()
}
···
import type { Database } from './db.ts'
import { Jetstream } from '@skyware/jetstream'
import { logger } from './lib/logger.ts'
+
import { Router } from './lib/router.ts'
+
import { createRoutes } from './routes.ts'
dotenv.config()
export type Context = {
db: Database
+
jetstream: Jetstream,
+
httpServer: Router
}
export class Server {
···
}
static async create() {
+
const port = process.env.PORT || "8080"
+
const dbPath = process.env.DB_PATH || ":memory"
+
+
const db = createDb(dbPath)
await migrateToLatest(db)
+
logger.info('Migrating db to latest version')
+
const ingester = createIngester(db)
await ingester.backfill()
const jetstream = await ingester.jetstream()
jetstream.start()
logger.info("Starting jetstream client")
+
const httpServer = new Router()
+
createRoutes(httpServer)
+
+
const ctx: Context = {
+
db,
+
jetstream,
+
httpServer
+
}
+
+
httpServer.start(port)
+
logger.info(`Starting http server on port ${port}`)
+
return new Server(ctx)
}
async close() {
logger.info("Stopping jetstream client")
await this.ctx.jetstream.close()
+
await this.ctx.httpServer.close()
return new Promise<void>((resolve) => {
resolve()
···
const onClose = async () => {
setTimeout(() => process.exit(1), 10000).unref()
+
// await server.close()
process.exit()
}
+6 -6
src/lib/logger.ts
···
FATAL: 1,
} as const
-
export function log(level: number, type: string, message: string): void {
const envLevel = process.env.LOG_LEVEL ? logLevel[process.env.LOG_LEVEL] : logLevel.INFO
if (level > envLevel) return
console.log(`${type} ${new Date().toISOString()}: ${message}`)
}
-
function logDebug(message: string): void {
log(logLevel.DEBUG, "[DEBUG]", message)
}
-
function logInfo(message: string): void {
log(logLevel.INFO, "[INFO]", message)
}
-
function logWarn(message: string): void {
log(logLevel.WARN, "[WARN]", message)
}
-
function logError(message: string): void {
log(logLevel.ERROR, "[WARN]", message)
}
-
function logFatal(message: string): void {
log(logLevel.ERROR, "[WARN]", message)
}
···
FATAL: 1,
} as const
+
export function log(level: number, type: string, message: any): void {
const envLevel = process.env.LOG_LEVEL ? logLevel[process.env.LOG_LEVEL] : logLevel.INFO
if (level > envLevel) return
console.log(`${type} ${new Date().toISOString()}: ${message}`)
}
+
function logDebug(message: any): void {
log(logLevel.DEBUG, "[DEBUG]", message)
}
+
function logInfo(message: any): void {
log(logLevel.INFO, "[INFO]", message)
}
+
function logWarn(message: any): void {
log(logLevel.WARN, "[WARN]", message)
}
+
function logError(message: any): void {
log(logLevel.ERROR, "[WARN]", message)
}
+
function logFatal(message: any): void {
log(logLevel.ERROR, "[WARN]", message)
}
+81
src/lib/router.ts
···
···
+
import { createServer, Server, IncomingMessage, ServerResponse } from "node:http";
+
import { logger } from "./logger.ts";
+
+
interface Callback {
+
(req: IncomingMessage, res: ServerResponse, params?: {[key: string]: string}): void
+
}
+
+
interface Route {
+
method: string,
+
path: RegExp,
+
callback: Callback
+
}
+
+
export class Router {
+
private routes:Array<Route>
+
private server:Server<typeof IncomingMessage, typeof ServerResponse> | null
+
+
readonly ROUTE_MATCHER = /(?:{([a-z0-9-_]+):([a-z]+)})/ig
+
readonly ROUTE_SUBSTITUTION = "(?<$1>$2)"
+
readonly PARAM_TYPES: {[key:string]: string} = {
+
all: "[a-z0-9-_]+",
+
number: "[0-9]+",
+
string: "[a-z-_]+"
+
}
+
+
constructor() {
+
this.routes = []
+
this.server = null
+
}
+
+
route(method:string, path:string, callback:Callback) {
+
let replacedPath = `^${path.replace(this.ROUTE_MATCHER, this.ROUTE_SUBSTITUTION)}$`
+
+
for (let type in this.PARAM_TYPES) {
+
if (replacedPath.includes(type)) {
+
replacedPath = replacedPath.replace(type, this.PARAM_TYPES[type])
+
}
+
}
+
+
this.routes.push({
+
method: method,
+
path: new RegExp(replacedPath, "i"),
+
callback
+
})
+
}
+
+
get(path:string, callback:Callback) {
+
this.route('GET', path, callback)
+
}
+
+
handleHttpRequest() {
+
return (req:IncomingMessage, res:ServerResponse) => {
+
+
for (let route of this.routes) {
+
let match = req.url?.match(route.path)
+
console.log(route.path, req.url)
+
+
if (!match) continue;
+
+
route.callback(req, res, match.groups)
+
return
+
}
+
+
res.end('HTTP/1.1 400 Bad Request\r\n\r\n')
+
}
+
}
+
+
start(port:string) {
+
this.server = createServer(this.handleHttpRequest())
+
+
this.server.on('clientError', (err, socket) => {
+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
+
})
+
+
this.server.listen(port)
+
}
+
+
async close() {
+
this.server?.close()
+
}
+
}
+12
src/routes.ts
···
···
+
import { Router } from "./lib/router.ts";
+
+
export const createRoutes = (router:Router) => {
+
+
router.get('/', (req, res) => {
+
res.end()
+
})
+
+
router.get('/page/{page:number}', (req, res, params) => {
+
res.end()
+
})
+
}