Skip to content

Commit ed9125a

Browse files
committed
Add NORAD ID endpoint
1 parent f85386d commit ed9125a

File tree

8 files changed

+115
-2
lines changed

8 files changed

+115
-2
lines changed

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"keyv": "^5.6.0",
2727
"keyv-file": "^5.3.3",
2828
"prom-client": "^15.1.3",
29+
"tle": "^3.0.1",
2930
"winston": "^3.19.0"
3031
}
3132
}

src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Elysia } from "elysia";
22
import { html } from "@elysiajs/html";
33

44
import tleRoute from "./routes/tle";
5+
import noradRoute from "./routes/norad";
56

67
import index from "./pub/index.tsx";
78
import kv from "./utils/kv";
@@ -24,6 +25,7 @@ new Elysia()
2425

2526
// Subroutes registers
2627
.use(tleRoute) // Import TLE routes
28+
.use(noradRoute) // Import NORAD routes
2729

2830
.listen(config.port, () => {
2931
log.info(`Server is running on port ${config.port}`);

src/routes/norad.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Elysia } from "elysia";
2+
3+
import tleGetter from "../utils/tleGetter";
4+
5+
const noradRoute = new Elysia({ prefix: "/norad" }).get("/:id", async ({ params }) => {
6+
const noradId = parseInt(params.id, 10);
7+
const tleData = await tleGetter(noradId);
8+
return new Response(tleData, { headers: { "Content-Type": "text/plain", "Cache-Control": "max-age=3600" } });
9+
});
10+
11+
export default noradRoute;

src/utils/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
const config = {
22
allowedGroups: process.env.ALLOWED_GROUPS ? process.env.ALLOWED_GROUPS.split(",").concat("active") : ["active"],
3+
logLevel: process.env.LOG_LEVEL || "info",
34
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
45
rateLimitWindow: process.env.RATE_LIMIT_WINDOW ? parseInt(process.env.RATE_LIMIT_WINDOW) * 1000 : 60 * 1000,
56
rateLimitMaxRequests: process.env.RATE_LIMIT_MAX_REQUESTS ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) : 60,
67
cacheDuration: process.env.CACHE_DURATION ? parseInt(process.env.CACHE_DURATION) * 1000 : 24 * 60 * 60 * 1000,
78
cacheActiveDuration: process.env.CACHE_ACTIVE_DURATION ? parseInt(process.env.CACHE_ACTIVE_DURATION) * 1000 : 2 * 60 * 60 * 1000,
9+
cacheNoradDuration: process.env.CACHE_NORAD_DURATION ? parseInt(process.env.CACHE_NORAD_DURATION) * 1000 : 24 * 60 * 60 * 1000,
810
};
911

1012
export default config;

src/utils/logger.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import winston from "winston";
22

3+
import config from "./config";
4+
35
const log = winston.createLogger({
4-
level: process.env.LOG_LEVEL || "info",
6+
level: config.logLevel,
57
format: winston.format.combine(
68
winston.format.timestamp(),
79
winston.format.colorize(),

src/utils/tleFetcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ async function fetchTle(group: string) {
1111
const response = await fetch(url, {
1212
headers: {
1313
"If-Modified-Since": lastFetch ? new Date(lastFetch).toUTCString() : "",
14-
"User-Agent": `ReTLEctor/${version} (https://github.com/MrTalon63/retlector)`,
14+
"User-Agent": `ReTLEctor/${version} (https://github.com/MrTalon63/ReTLEctor)`,
1515
},
1616
});
1717
if (!response.ok) {

src/utils/tleGetter.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import tle from "tle";
2+
3+
import kv from "./kv";
4+
import log from "./logger";
5+
import config from "./config";
6+
import fetchTle from "./tleFetcher";
7+
import { version } from "../../package.json";
8+
9+
async function getObjectsTle(noradId: number) {
10+
let tleData = (await kv.get(`tle_${noradId}`)) as string | null;
11+
let timestamp = await kv.get(`tle_${noradId}_timestamp`);
12+
const now = Date.now();
13+
const isStale = timestamp ? now - timestamp > config.cacheNoradDuration : true;
14+
15+
if (!tleData || isStale) {
16+
let allTles = (await kv.get("active")) as string | null;
17+
if (!allTles) {
18+
await fetchTle("active");
19+
}
20+
allTles = (await kv.get("active")) as string | null;
21+
22+
// If we still don't have the active TLEs then something went wrong with fetching, so we throw an error
23+
if (!allTles) {
24+
log.error("Failed to fetch active TLEs from Celestrak");
25+
throw new Error("Failed to fetch active TLEs from Celestrak");
26+
}
27+
const lines = allTles.split("\n");
28+
const setPromises: Promise<boolean>[] = [];
29+
for (let i = 0; i < lines.length; i += 3) {
30+
const idLine = lines[i + 0];
31+
const tleLine1 = lines[i + 1];
32+
const tleLine2 = lines[i + 2];
33+
34+
if (idLine && tleLine1 && tleLine2) {
35+
const parsed = tle.parse(`${idLine}\n${tleLine1}\n${tleLine2}`);
36+
const tleString = `${idLine}\n${tleLine1}\n${tleLine2}`;
37+
if (parsed.number === noradId) {
38+
tleData = tleString;
39+
}
40+
setPromises.push(kv.set(`tle_${parsed.number}`, tleString));
41+
}
42+
}
43+
await Promise.all(setPromises);
44+
}
45+
46+
// If we're here then active group doesn't contain the requested NORAD ID, try fetching the TLE directly from Celestrak, but be aware of rate limits so we don't get blocked
47+
if (!tleData) {
48+
log.verbose(`NORAD ID ${noradId} not found in active group. Attempting to fetch directly from Celestrak...`);
49+
const url = `https://celestrak.org/NORAD/elements/gp.php?CATNR=${noradId}&FORMAT=tle`;
50+
try {
51+
let tries: number = (await kv.get(`celestrakTries`)) || 0;
52+
const lastTry: number | undefined = await kv.get(`celestrakLastTry`);
53+
54+
// Reset the counters if more than an hour has passed since the last request
55+
if (lastTry && Date.now() - lastTry >= 60 * 60 * 1000) {
56+
tries = 0;
57+
await kv.set(`celestrakTries`, 0);
58+
await kv.set(`celestrakLastTry`, Date.now());
59+
}
60+
61+
// We don't want to spam Celestrak with more than 20 requests per hour
62+
if (tries >= 20) {
63+
throw new Error("Rate limit exceeded for Celestrak fetches");
64+
}
65+
66+
const response = await fetch(url, {
67+
// Celestrak doesn't support If-Modified-Since or ETag headers, so we have to implement our own rate limiting and caching mechanism to avoid getting blocked
68+
headers: {
69+
"User-Agent": `ReTLEctor/${version} (https://github.com/MrTalon63/ReTLEctor)`,
70+
},
71+
});
72+
73+
if (!response.ok) {
74+
log.error(`Failed to fetch TLE from Celestrak: ${response.status} ${response.statusText}`);
75+
throw new Error(`Failed to fetch TLE from Celestrak`);
76+
}
77+
78+
tleData = (await response.text()) as string;
79+
await kv.set(`tle_${noradId}`, tleData);
80+
await kv.set(`celestrakTries`, tries + 1);
81+
await kv.set(`celestrakLastTry`, Date.now());
82+
log.info(`Successfully fetched TLE for NORAD ID ${noradId} from Celestrak.`);
83+
} catch (error) {
84+
log.error(`Error fetching TLE for NORAD ID ${noradId}: ${error}`);
85+
throw error;
86+
}
87+
}
88+
89+
return tleData;
90+
}
91+
92+
export default getObjectsTle;

0 commit comments

Comments
 (0)