Skip to content

Commit b2f6bce

Browse files
committed
refactor: errors, structure
1 parent 32d0077 commit b2f6bce

File tree

6 files changed

+73
-87
lines changed

6 files changed

+73
-87
lines changed

bun.lockb

-703 Bytes
Binary file not shown.

package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,10 @@
2020
},
2121
"dependencies": {
2222
"radash": "^12.1.0",
23-
"uuid": "^9.0.1",
2423
"zod": "^3.22.4"
2524
},
2625
"devDependencies": {
27-
"@types/bun": "latest",
28-
"@types/uuid": "^9.0.8"
26+
"@types/bun": "latest"
2927
},
3028
"peerDependencies": {
3129
"typescript": "^5.0.0"

src/fetch-metro/fetch-metro.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { endpointUrl, headers, paramsAsArray } from "./fetch-metro.const";
44
import { ApiResponseSchema } from "../schemas";
55

66
export const fetchApiData = async (stopIDs: string[]) => {
7-
if (!stopIDs.length) return { departures: [] };
7+
if (!stopIDs.length) {
8+
return { departures: [] };
9+
}
810

911
const stopIDsParams = unique(stopIDs).map(
1012
(id) => ["ids[]", id] satisfies [string, string]
@@ -23,6 +25,7 @@ export const fetchApiData = async (stopIDs: string[]) => {
2325

2426
const body = await res.json();
2527
const parsed = ApiResponseSchema.safeParse(body);
28+
2629
if (!parsed.success) {
2730
console.error("Golemio API response:", body);
2831
throw new Error("Golemio API response is in unexpected format");

src/schemas.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { z } from "zod";
22
import { MetroLine } from "./types";
33
import { stopIDs } from "./data/stop-ids";
44

5-
export const StopIDsSchema = z.array(z.enum(stopIDs)).nonempty().max(10);
5+
const stopIDSchema = z.enum(stopIDs);
6+
7+
export const StopIDsSchema = z.array(stopIDSchema).nonempty().max(10);
68

79
export const SubscribeSchema = z.object({
810
subscribe: StopIDsSchema,
@@ -27,7 +29,7 @@ export const ApiResponseSchema = z.object({
2729
short_name: z.nativeEnum(MetroLine),
2830
}),
2931
stop: z.object({
30-
id: z.string(),
32+
id: stopIDSchema,
3133
}),
3234
trip: z.object({
3335
headsign: z.string(),

src/server.ts

Lines changed: 64 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import type { ServerWebSocket } from "bun";
22
import type { ClientData, Departure } from "./types";
33

4-
import { v4 as uuid } from "uuid";
54
import { group } from "radash";
65

76
import { STOP_IDS_HEADER, INTERVAL } from "./server/server.const";
87
import { fetchApiData } from "./fetch-metro/fetch-metro";
98
import { StopIDsSchema, SubscribeSchema, type ApiResponse } from "./schemas";
10-
import { getErrorResponse, getParsedDeparture } from "./server/server.utils";
9+
import { getParsedDeparture } from "./server/server.utils";
1110

1211
if (!process.env.GOLEMIO_API_KEY) {
1312
throw new Error("GOLEMIO_API_KEY is not set in .env");
@@ -20,23 +19,41 @@ const wsByClientID = new Map<string, ServerWebSocket<ClientData>>();
2019

2120
const departuresByStopID = new Map<string, Departure[]>();
2221

23-
const getAllSubscribedStopIDs = (): string[] => {
22+
/**
23+
* If clientID is provided, fetch only the stopIDs that the client is subscribed to
24+
* and that haven't been fetched yet.
25+
*
26+
* Otherwise, refetch all stopIDs that are subscribed by any client.
27+
*/
28+
const getStopIDsToFetch = (clientID?: string): string[] => {
29+
if (clientID !== undefined) {
30+
const subscribedStopIDs = subscribedStopIDsByClientID.get(clientID) ?? [];
31+
const notFetchedStopIDs = subscribedStopIDs.filter(
32+
(stopID) => !departuresByStopID.has(stopID)
33+
);
34+
return notFetchedStopIDs;
35+
}
36+
2437
const stopIDsByClientIDMapValues = subscribedStopIDsByClientID.values();
25-
return Array.from(stopIDsByClientIDMapValues).flat();
38+
return [...stopIDsByClientIDMapValues].flat();
39+
};
40+
41+
/**
42+
* Return only the data that the client is subscribed to
43+
* as stringified object
44+
*/
45+
const getStringifiedDataForClientID = (clientID: string): string => {
46+
const clientsStopIDs = subscribedStopIDsByClientID.get(clientID)!;
47+
const dataForClientEntries = clientsStopIDs.map((stopID) => [
48+
stopID,
49+
departuresByStopID.get(stopID),
50+
]);
51+
const dataForClient = Object.fromEntries(dataForClientEntries);
52+
return JSON.stringify(dataForClient);
2653
};
2754

2855
const fetchData = async (clientID?: string) => {
29-
/**
30-
* If clientID is provided, fetch only the stopIDs that the client is subscribed to
31-
* and that haven't been fetched yet.
32-
*
33-
* Otherwise, refetch all stopIDs that are subscribed by any client.
34-
*/
35-
const stopIDsToFetch: string[] = clientID
36-
? subscribedStopIDsByClientID
37-
.get(clientID)!
38-
.filter((stopID) => !departuresByStopID.has(stopID))
39-
: getAllSubscribedStopIDs();
56+
const stopIDsToFetch: string[] = getStopIDsToFetch(clientID);
4057

4158
const res = await fetchApiData(stopIDsToFetch);
4259
/**
@@ -61,65 +78,47 @@ const fetchData = async (clientID?: string) => {
6178
});
6279
}
6380

64-
/**
65-
* Return only the data that the client is subscribed to
66-
* as stringified object
67-
*/
68-
const getStringifiedDataForClientID = (clientID: string): string => {
69-
const stopIDsSubscribedByClient =
70-
subscribedStopIDsByClientID.get(clientID)!;
71-
const dataForClient = Object.fromEntries(
72-
stopIDsSubscribedByClient.map((stopID) => [
73-
stopID,
74-
departuresByStopID.get(stopID),
75-
])
76-
);
77-
return JSON.stringify(dataForClient);
78-
};
79-
80-
/**
81-
* If clientID is provided, send data to the client
82-
*/
81+
// If clientID is provided, send data only to the client
8382
if (clientID) {
8483
const ws = wsByClientID.get(clientID)!;
85-
8684
ws.send(getStringifiedDataForClientID(clientID));
87-
8885
return;
8986
}
9087

91-
/**
92-
* If clientID is not provided, send data to all clients
93-
*/
88+
// If clientID is not provided, send data to all clients
9489
wsByClientID.forEach((ws, clientID) =>
9590
ws.send(getStringifiedDataForClientID(clientID))
9691
);
9792
};
9893

9994
const server = Bun.serve<ClientData>({
10095
fetch(req, server) {
101-
const stopIDsHeaderRaw = req.headers.get(STOP_IDS_HEADER);
102-
if (!stopIDsHeaderRaw)
103-
return getErrorResponse(`"${STOP_IDS_HEADER}" header is missing`);
104-
105-
let StopIDsHeaderParsed: unknown;
10696
try {
107-
StopIDsHeaderParsed = JSON.parse(stopIDsHeaderRaw);
108-
} catch (error) {
109-
return getErrorResponse(`"${STOP_IDS_HEADER}" header ${error}`);
110-
}
97+
const stopIDsHeaderRaw = req.headers.get(STOP_IDS_HEADER);
98+
if (!stopIDsHeaderRaw) {
99+
throw `"${STOP_IDS_HEADER}" header is missing`;
100+
}
111101

112-
const res = StopIDsSchema.safeParse(StopIDsHeaderParsed);
113-
if (!res.success)
114-
return getErrorResponse(
115-
`"${STOP_IDS_HEADER}" error: ${res.error.errors[0].message}`
116-
);
102+
const StopIDsHeaderParsed: unknown = JSON.parse(stopIDsHeaderRaw);
103+
const res = StopIDsSchema.safeParse(StopIDsHeaderParsed);
104+
if (!res.success) {
105+
throw (
106+
`Invalid "${STOP_IDS_HEADER}" header: ` +
107+
JSON.stringify(res.error.errors[0].message)
108+
);
109+
}
117110

118-
const clientID = uuid();
119-
subscribedStopIDsByClientID.set(clientID, res.data);
120-
const success = server.upgrade(req, { data: { clientID } });
111+
const clientID = crypto.randomUUID();
112+
subscribedStopIDsByClientID.set(clientID, res.data);
121113

122-
if (!success) return getErrorResponse("Failed to upgrade connection");
114+
const success = server.upgrade(req, { data: { clientID } });
115+
if (!success) throw "Failed to upgrade connection";
116+
} catch (e) {
117+
return new Response(String(e), {
118+
status: 500,
119+
headers: [["error", String(e)]], // Postman doesn't show response body when testing websocket
120+
});
121+
}
123122
},
124123
websocket: {
125124
open(ws) {
@@ -135,26 +134,17 @@ const server = Bun.serve<ClientData>({
135134
intervalId = intervalObj[Symbol.toPrimitive]();
136135
},
137136
message(ws, message) {
138-
if (typeof message !== "string") {
139-
ws.close(1011, "Message has to be string");
140-
return;
141-
}
142-
143-
let StopIDsHeaderParsed: unknown;
144137
try {
145-
StopIDsHeaderParsed = JSON.parse(message);
146-
} catch (error) {
147-
ws.close(1011, String(error));
148-
return;
149-
}
138+
if (typeof message !== "string") throw "Message has to be string";
150139

151-
const res = SubscribeSchema.safeParse(StopIDsHeaderParsed);
152-
if (!res.success) {
153-
ws.close(1011, res.error.errors[0].message);
154-
return;
155-
}
140+
var StopIDsHeaderParsed: unknown = JSON.parse(message);
141+
const res = SubscribeSchema.safeParse(StopIDsHeaderParsed);
142+
if (!res.success) throw JSON.stringify(res.error.errors[0].message);
156143

157-
subscribedStopIDsByClientID.set(ws.data.clientID, res.data.subscribe);
144+
subscribedStopIDsByClientID.set(ws.data.clientID, res.data.subscribe);
145+
} catch (e) {
146+
ws.close(1011, String(e));
147+
}
158148
},
159149
close(ws) {
160150
const clientID = ws.data.clientID;

src/server/server.utils.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
import type { Departure } from "../types";
22
import type { ApiResponse } from "../schemas";
33

4-
export const getErrorResponse = (message: string): Response => {
5-
return new Response(message, {
6-
status: 500,
7-
headers: [["error", message]], // Postman doesn't show response body when testing websocket
8-
});
9-
};
10-
114
export const getParsedDeparture = (
125
departure: ApiResponse["departures"][0]
136
): Departure => {

0 commit comments

Comments
 (0)