Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@ const lTrain = await mta.subway.arrivals({
});
```

Arrival rows may include display-oriented fields. `destination` depends on
destination metadata, while `displayDirection` may still be present as a
generic fallback label:

```ts
for (const arrival of lTrain) {
const direction =
arrival.displayDirection ??
(arrival.destination
? `toward ${arrival.destination}`
: arrival.headsign
? `toward ${arrival.headsign}`
: arrival.direction);

console.log(`${arrival.route.shortName} ${direction} from ${arrival.stop.name}`);
}
```

NYC Subway realtime feeds use NYCT's `north`/`south` stop directions, even on
east-west lines. For the L train, `mta-js` accepts rider-facing `east`/`west`
aliases and maps them to the underlying feed directions.

When `apiKey` is present, `mta-js` sends requests to the hosted API at
`https://www.mtaapi.dev` by default. Override `apiBaseUrl` for tests or private
deployments.
Expand Down
132 changes: 132 additions & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ const staticData = {
stop_lat: 40.682,
stop_lon: -73.978,
},
{
stop_id: "L06",
stop_name: "1 Av",
stop_lat: 40.730953,
stop_lon: -73.981628,
},
{
stop_id: "L06S",
stop_name: "1 Av",
stop_lat: 40.730953,
stop_lon: -73.981628,
parent_station: "L06",
},
{
stop_id: "L06N",
stop_name: "1 Av",
stop_lat: 40.730953,
stop_lon: -73.981628,
parent_station: "L06",
},
],
routes: [
{
Expand All @@ -40,6 +60,13 @@ const staticData = {
route_long_name: "Bay Ridge - Cobble Hill",
route_type: 3,
},
{
route_id: "L",
route_short_name: "L",
route_long_name: "14 St-Canarsie Local",
route_type: 1,
route_color: "7C858C",
},
],
trips: [
{
Expand All @@ -49,6 +76,20 @@ const staticData = {
trip_headsign: "Inwood-207 St",
direction_id: 0,
},
{
route_id: "L",
service_id: "weekday",
trip_id: "L-trip-1",
trip_headsign: "Canarsie-Rockaway Pkwy",
direction_id: 1,
},
{
route_id: "L",
service_id: "weekday",
trip_id: "L-trip-2",
trip_headsign: "8 Av",
direction_id: 0,
},
],
};

Expand Down Expand Up @@ -77,6 +118,25 @@ test("hosted API calls include bearer and x-api-key auth headers", async () => {
expect(request?.headers.get("x-api-key")).toBe("test-key");
});

test("hosted subway arrivals infer L route and normalize east aliases before requesting the API", async () => {
let request: Request | undefined;
const mta = new MTA({
apiKey: "test-key",
apiBaseUrl: "https://api.example.com",
fetch: (async (input, init) => {
request = input instanceof Request ? new Request(input, init) : new Request(String(input), init);
return Response.json([]);
}) as typeof fetch,
});

await mta.subway.arrivals({ stopId: "L06", direction: "east" });

const url = new URL(request?.url ?? "");
expect(url.pathname).toBe("/api/v1/subway/arrivals");
expect(url.searchParams.get("route")).toBe("L");
expect(url.searchParams.get("direction")).toBe("south");
});

test("subway arrivals decode GTFS realtime and join in-memory static metadata", async () => {
const feed = encodeFeedMessage({
header: { gtfsRealtimeVersion: "2.0", timestamp: 1_700_000_000 },
Expand Down Expand Up @@ -120,12 +180,81 @@ test("subway arrivals decode GTFS realtime and join in-memory static metadata",
name: "Jay St-MetroTech",
},
direction: "north",
destination: "Inwood-207 St",
displayDirection: "toward Inwood-207 St",
headsign: "Inwood-207 St",
tripId: "A-trip-1",
source: "mta-gtfs-rt",
});
});

test("subway arrivals map L east and west aliases to NYCT north and south directions", async () => {
const feed = encodeFeedMessage({
header: { gtfsRealtimeVersion: "2.0", timestamp: 1_700_000_000 },
entity: [
{
id: "arrival-1",
tripUpdate: {
trip: { tripId: "L-trip-1", routeId: "L" },
stopTimeUpdate: [
{
stopId: "L06S",
arrival: { time: 1_700_000_300 },
},
],
},
},
{
id: "arrival-2",
tripUpdate: {
trip: { tripId: "L-trip-2", routeId: "L" },
stopTimeUpdate: [
{
stopId: "L06N",
arrival: { time: 1_700_000_360 },
},
],
},
},
],
});

const mta = new MTA({
staticData,
fetch: (async () => new Response(feed)) as unknown as typeof fetch,
endpoints: {
subwayFeeds: { L: "feed://l" },
},
});

const eastboundArrivals = await mta.subway.arrivals({ stopId: "L06", direction: "east" });
const westboundArrivals = await mta.subway.arrivals({ stopId: "L06", direction: "west" });

expect(eastboundArrivals).toHaveLength(1);
expect(eastboundArrivals[0]).toMatchObject({
route: {
id: "L",
shortName: "L",
longName: "14 St-Canarsie Local",
},
stop: {
id: "L06",
name: "1 Av",
},
direction: "south",
destination: "Canarsie-Rockaway Pkwy",
displayDirection: "toward Canarsie-Rockaway Pkwy",
headsign: "Canarsie-Rockaway Pkwy",
});
expect(westboundArrivals).toHaveLength(1);
expect(westboundArrivals[0]).toMatchObject({
direction: "north",
destination: "8 Av",
displayDirection: "toward 8 Av",
headsign: "8 Av",
});
});

test("bus arrivals normalize BusTime responses with in-memory static metadata", async () => {
let requestedUrl = "";
const mta = new MTA({
Expand Down Expand Up @@ -168,6 +297,9 @@ test("bus arrivals normalize BusTime responses with in-memory static metadata",
mode: "bus",
route: { id: "B63", shortName: "B63" },
stop: { id: "308214", name: "5 Av/Atlantic Av" },
destination: "Cobble Hill",
displayDirection: "toward Cobble Hill",
headsign: "Cobble Hill",
source: "mta-bustime",
});
});
Expand Down
51 changes: 42 additions & 9 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,22 +109,23 @@ class SubwayClient {
constructor(private readonly mta: MTA) {}

async arrivals(query: SubwayArrivalQuery): Promise<Arrival[]> {
const normalizedQuery = normalizeSubwayArrivalQuery(query);
if (this.mta.hostedApiEnabled()) {
return this.mta.hostedJson<Arrival[]>("/api/v1/subway/arrivals", query);
return this.mta.hostedJson<Arrival[]>("/api/v1/subway/arrivals", normalizedQuery);
}

await this.mta.ready();
const routeIds = query.route ? [normalizeRouteId(query.route)] : Object.keys(this.mta.endpoints.subwayFeeds);
const routeIds = normalizedQuery.route ? [normalizeRouteId(normalizedQuery.route)] : Object.keys(this.mta.endpoints.subwayFeeds);
const feeds = [...new Set(routeIds.map((route) => this.feedForRoute(route)))];
const stopIds = this.mta.static.getStopIdsForQuery(query.stopId);
if (this.mta.static.hasStaticData("subway") && !this.mta.static.getStopOrParent(query.stopId)) {
throw new UnknownStopError(query.stopId);
const stopIds = this.mta.static.getStopIdsForQuery(normalizedQuery.stopId);
if (this.mta.static.hasStaticData("subway") && !this.mta.static.getStopOrParent(normalizedQuery.stopId)) {
throw new UnknownStopError(normalizedQuery.stopId);
}
const arrivals: Arrival[] = [];

for (const feedUrl of feeds) {
const feed = await this.mta.realtimeFeed(feedUrl);
arrivals.push(...this.arrivalsFromFeed(feed, stopIds, query));
arrivals.push(...this.arrivalsFromFeed(feed, stopIds, normalizedQuery));
}

return arrivals
Expand Down Expand Up @@ -170,12 +171,15 @@ class SubwayClient {
if (!event?.time) continue;

const stop = this.mta.static.getStopOrParent(stopId) ?? fallbackStop(query.stopId);
const headsign = staticTrip?.headsign ?? undefined;
arrivals.push({
mode: "subway",
route,
stop,
direction,
headsign: staticTrip?.headsign ?? undefined,
destination: headsign,
displayDirection: displayDirection(headsign, direction),
headsign,
arrivalTime: new Date(event.time * 1000).toISOString(),
departureTime: update.departure?.time ? new Date(update.departure.time * 1000).toISOString() : undefined,
minutes: Math.max(0, Math.round((event.time * 1000 - now) / 60_000)),
Expand Down Expand Up @@ -223,12 +227,15 @@ class BusClient {
const expected = call.ExpectedArrivalTime ?? call.AimedArrivalTime;
if (!expected) return undefined;
const stop = this.mta.static.getStop(String(call.StopPointRef ?? query.stopId)) ?? fallbackStop(query.stopId);
const headsign = stringOrUndefined(mvj.DestinationName);
return {
mode: "bus",
route: routeWithFallback(this.mta.static.getRoute(routeId), routeId),
stop,
direction: "unknown",
headsign: stringOrUndefined(mvj.DestinationName),
destination: headsign,
displayDirection: displayDirection(headsign, "unknown"),
headsign,
arrivalTime: new Date(expected).toISOString(),
minutes: Math.max(0, Math.round((Date.parse(expected) - now) / 60_000)),
tripId: stringOrUndefined(mvj.FramedVehicleJourneyRef?.DatedVehicleJourneyRef),
Expand Down Expand Up @@ -374,13 +381,39 @@ function normalizeRouteId(route: string) {
return route.toUpperCase().trim();
}

function normalizeDirection(direction: Direction | "uptown" | "downtown" | undefined): Direction | undefined {
function normalizeSubwayArrivalQuery(query: SubwayArrivalQuery): SubwayArrivalQuery {
const route = query.route ?? routeFromLStopId(query.stopId);
return {
...query,
route,
direction: normalizeDirection(query.direction, route),
};
}

function normalizeDirection(
direction: Direction | "uptown" | "downtown" | undefined,
route?: string,
): Direction | undefined {
if (!direction) return undefined;
if (direction === "uptown") return "north";
if (direction === "downtown") return "south";
if (route && normalizeRouteId(route) === "L") {
Comment thread
gtokman marked this conversation as resolved.
if (direction === "east") return "south";
if (direction === "west") return "north";
}
return direction;
}

function routeFromLStopId(stopId: string) {
return /^L\d{2}[NS]?$/.test(stopId.toUpperCase().trim()) ? "L" : undefined;
}

function displayDirection(headsign: string | undefined, direction: Direction) {
if (headsign) return `toward ${headsign}`;
if (direction === "unknown") return undefined;
return `${direction}bound`;
}

function routeWithFallback(route: Route | undefined, routeId: string): Route {
return (
route ?? {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mta-js",
"version": "2.1.0",
"version": "2.1.1",
"description": "A TypeScript client for MTA realtime feeds and the hosted MTA API.",
"license": "MIT",
"repository": {
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export interface Arrival {
route: Route;
stop: Stop;
direction: Direction;
destination?: string;
displayDirection?: string;
headsign?: string;
arrivalTime: string;
departureTime?: string;
Expand Down
Loading