A simple AtProto app to read pet.mewsse.link records on my PDS.
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}