A simple AtProto app to read pet.mewsse.link records on my PDS.
at main 4.2 kB view raw
1import { createServer, Server, IncomingMessage, ServerResponse } from "node:http" 2import { logger } from "./logger.ts" 3import process from 'node:process' 4import path from "node:path" 5import mime from "mime" 6import fs from "node:fs" 7 8interface Callback { 9 (req: IncomingMessage, res: ServerResponse, params?: { [key: string]: string }): void 10} 11 12interface Route { 13 method: string, 14 path: RegExp, 15 callback: Callback 16} 17 18export class Router { 19 private routes: Array<Route> 20 private server: Server<typeof IncomingMessage, typeof ServerResponse> | null 21 22 readonly ROUTE_MATCHER = /(?:{([a-z0-9-_]+):([a-z]+)})/ig 23 readonly ROUTE_SUBSTITUTION = "(?<$1>$2)" 24 readonly PARAM_TYPES: { [key: string]: string } = { 25 all: "[a-z0-9-_]+", 26 number: "[0-9]+", 27 string: "[a-z-_]+" 28 } 29 30 readonly NON_SAFE_CHARS = /[\x00-\x1F\x20\x7F-\uFFFF]+/g 31 readonly ASSETS_MATCHER = /^\/assets\/([a-z0-9.-_\/]+)|\/favicon.ico$/g 32 readonly IGNORE_FILES = [ 33 "assets", 34 "." 35 ] 36 37 constructor() { 38 this.routes = [] 39 this.server = null 40 } 41 42 route(method: string, path: string, callback: Callback) { 43 let replacedPath = `^${path.replace(this.ROUTE_MATCHER, this.ROUTE_SUBSTITUTION)}$` 44 45 for (let type in this.PARAM_TYPES) { 46 if (replacedPath.includes(type)) { 47 replacedPath = replacedPath.replace(type, this.PARAM_TYPES[type]) 48 } 49 } 50 51 this.routes.push({ 52 method: method, 53 path: new RegExp(replacedPath, "i"), 54 callback 55 }) 56 } 57 58 get(path: string, callback: Callback) { 59 this.route('GET', path, callback) 60 } 61 62 handleHttpRequest() { 63 return async (req: IncomingMessage, res: ServerResponse) => { 64 if (!req.method) { 65 res.end('HTTP/1.1 400 Bad Request\r\n\r\n') 66 logger.error(`400: ${req.url}`) 67 } 68 69 if ( 70 req.method && 71 req.url && 72 req.url.match(this.ASSETS_MATCHER) && 73 ['GET', 'HEAD'].includes(req.method) 74 ) { 75 const pathName = String(req.url) 76 .replace(this.NON_SAFE_CHARS, encodeURIComponent) 77 .split("/") 78 .filter((part) => part.length > 0 && !this.IGNORE_FILES.includes(part)) 79 .join('/') 80 const tryFile = path.normalize(path.join(process.cwd(), 'assets', pathName)) 81 82 let stat = null 83 84 try { 85 stat = await fs.promises.stat(tryFile) 86 87 if (!stat.isFile()) { 88 throw new Error("Not a file") 89 } 90 } catch (error) { 91 logger.error(error) 92 res.end('HTTP/1.1 404 Not Found\r\n\r\n') 93 return 94 } 95 96 const ext = path.extname(tryFile) 97 const type = mime.getType(ext) || 'application/octet-stream' 98 const etag = `${stat.size.toString(16)}-${stat.mtime.getTime().toString(16)}` 99 100 res.setHeader('Content-Type', type) 101 res.setHeader('Content-Length', stat.size) 102 res.setHeader('Cache-Control', `max-age=${60*60*24*7}, must-revalidate`) 103 res.setHeader('Etag', etag) 104 res.setHeader('Last-Modified', stat.mtime.toUTCString()) 105 logger.info(`${req.method} ${req.url}`) 106 107 if (req.method === "HEAD") { 108 if (req.headers["if-none-match"] && req.headers["if-none-match"] === etag) { 109 res.statusCode = 304 110 } 111 112 res.end() 113 return 114 } 115 116 const stream = fs.createReadStream(tryFile) 117 stream.pipe(res) 118 119 stream.on('error', (err) => { 120 logger.error(err) 121 stream.destroy() 122 }) 123 124 stream.on('end', () => { 125 res.end() 126 }) 127 128 return 129 } 130 131 for await (let route of this.routes) { 132 let match = req.url?.match(route.path) 133 134 if (route.method != req.method || !match) continue 135 136 logger.info(`${req.method} ${req.url}`) 137 await route.callback(req, res, match.groups) 138 return 139 } 140 141 res.end('HTTP/1.1 404 Not Found\r\n\r\n') 142 logger.error(`404: ${req.method} ${req.url}`) 143 } 144 } 145 146 start(port: string) { 147 this.server = createServer(this.handleHttpRequest()) 148 149 this.server.on('clientError', (err, socket) => { 150 socket.end('HTTP/1.1 400 Bad Request\r\n\r\n') 151 }) 152 153 this.server.listen(port) 154 } 155 156 async close() { 157 this.server?.close() 158 } 159}