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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ For production static stop search, prefer the hosted API. It serves a compact
Blob-backed snapshot instead of requiring each SDK consumer to manage GTFS
imports.

Route and stop inputs include generated autocomplete for known MTA values while
remaining permissive for future route and stop additions. Refresh the generated
types from the hosted stops snapshot by setting `MTA_STOPS_SNAPSHOT_URL` or
`NEXT_PUBLIC_MTA_STOPS_SNAPSHOT_URL` and running `bun run generate:types`. Bus
route autocomplete is generated from public MTA borough GTFS route files by
default; set `MTA_BUS_GTFS_URLS` to a comma-separated list of GTFS zip URLs to
override them.

## Endpoints

- `mta.subway.arrivals(...)`
Expand Down
12 changes: 4 additions & 8 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
Route,
Stop,
StopsNearQuery,
SubwayArrivalQuery,
TransitMode,
Vehicle,
} from "./src/types";
Expand Down Expand Up @@ -107,13 +108,7 @@ export class MTA {
class SubwayClient {
constructor(private readonly mta: MTA) {}

async arrivals(query: {
stopId: string;
route?: string;
direction?: Direction | "uptown" | "downtown";
limit?: number;
includeRaw?: boolean;
}): Promise<Arrival[]> {
async arrivals(query: SubwayArrivalQuery): Promise<Arrival[]> {
if (this.mta.hostedApiEnabled()) {
return this.mta.hostedJson<Arrival[]>("/api/v1/subway/arrivals", query);
}
Expand Down Expand Up @@ -146,7 +141,7 @@ class SubwayClient {
private arrivalsFromFeed(
feed: GtfsRealtimeFeed,
stopIds: Set<string>,
query: { stopId: string; route?: string; direction?: Direction | "uptown" | "downtown"; includeRaw?: boolean },
query: SubwayArrivalQuery,
) {
const arrivals: Arrival[] = [];
const wantedDirection = normalizeDirection(query.direction);
Expand Down Expand Up @@ -481,4 +476,5 @@ function alertMatchesMode(
export { decodeFeedMessage, encodeFeedMessage } from "./src/gtfs-realtime";
export { GTFSCache } from "./src/static-gtfs";
export * from "./src/errors";
export type * from "./src/generated";
export type * from "./src/types";
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"files": [
"index.ts",
"src",
"scripts",
"examples",
"README.md"
],
Expand All @@ -20,6 +21,7 @@
},
"types": "./index.ts",
"scripts": {
"generate:types": "bun scripts/generate-known-types.ts",
"test": "bun test",
"typecheck": "bunx tsc --noEmit"
},
Expand Down
193 changes: 193 additions & 0 deletions scripts/generate-known-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { parse } from "csv-parse/sync"
import { unzipSync } from "fflate"

const defaultBusGtfsUrls = [
"https://rrgtfsfeeds.s3.amazonaws.com/gtfs_b.zip",
"https://rrgtfsfeeds.s3.amazonaws.com/gtfs_bx.zip",
"https://rrgtfsfeeds.s3.amazonaws.com/gtfs_m.zip",
"https://rrgtfsfeeds.s3.amazonaws.com/gtfs_q.zip",
"https://rrgtfsfeeds.s3.amazonaws.com/gtfs_si.zip",
]

const snapshotUrl =
process.env.MTA_STOPS_SNAPSHOT_URL ??
process.env.NEXT_PUBLIC_MTA_STOPS_SNAPSHOT_URL

if (!snapshotUrl) {
throw new Error(
"MTA_STOPS_SNAPSHOT_URL or NEXT_PUBLIC_MTA_STOPS_SNAPSHOT_URL is required to generate known MTA types.",
)
}
const busGtfsUrls = (
process.env.MTA_BUS_GTFS_URLS ??
process.env.MTA_BUS_GTFS_URL ??
defaultBusGtfsUrls.join(",")
)
.split(",")
.map((url) => url.trim())
.filter(Boolean)

type SnapshotStop = {
id: string
mode?: string
}

type SnapshotRoute = {
id?: string
shortName?: string
type?: number
}

type Snapshot = {
generatedAt?: string
stops?: SnapshotStop[]
stopRoutes?: Record<string, SnapshotRoute[]>
indexes?: {
routesToStops?: Record<string, string[]>
}
}

type GtfsRoute = {
route_id: string
route_short_name?: string
route_long_name?: string
route_type?: string
route_color?: string
route_text_color?: string
}

const snapshot = (await fetch(snapshotUrl).then((response) => {
if (!response.ok) {
throw new Error(`Unable to fetch stops snapshot: ${response.status} ${response.statusText}`)
}
return response.json()
})) as Snapshot

const stops = snapshot.stops ?? []
const routeEntries = Object.entries(snapshot.indexes?.routesToStops ?? {})
const routeMetadata = new Map<string, SnapshotRoute>()
const routeTypes = new Map<string, number>()

for (const route of await loadBusGtfsRoutes(busGtfsUrls)) {
for (const id of [route.id, route.shortName]) {
if (!id) continue
routeMetadata.set(id, route)
routeTypes.set(id, 3)
}
}

for (const routes of Object.values(snapshot.stopRoutes ?? {})) {
for (const route of routes) {
for (const id of [route.id, route.shortName]) {
if (!id) continue
routeMetadata.set(id, route)
if (route.type !== undefined) routeTypes.set(id, route.type)
}
}
}

function aliasesForRoute(routeId: string) {
const route = routeMetadata.get(routeId)
const aliases = new Set([routeId, route?.id, route?.shortName].filter((id): id is string => Boolean(id)))

for (const id of [...aliases]) {
if (id.endsWith("-SBS")) aliases.add(id.replace(/-SBS$/, ""))
if (id.endsWith("+")) aliases.add(id.replace(/\+$/, ""))
}

return [...aliases]
}

function routeType(routeId: string) {
return (
routeTypes.get(routeId) ??
routeTypes.get(`${routeId}-SBS`) ??
routeTypes.get(`${routeId}+`) ??
routeMetadata.get(routeId)?.type
)
}

function looksLikeSubwayRoute(routeId: string) {
if (routeType(routeId) !== undefined) return false
return /^[A-Z0-9]{1,2}$/.test(routeId)
}

const subwayRouteAliases = new Set(["SI", "SIR"])

const routeIds = [
...new Set([
...routeEntries.flatMap(([routeId]) => aliasesForRoute(routeId)),
...[...routeMetadata.keys()].flatMap((routeId) => aliasesForRoute(routeId)),
]),
]
const subwayRoutes = routeIds.filter(
(routeId) =>
routeType(routeId) === 1 ||
looksLikeSubwayRoute(routeId) ||
subwayRouteAliases.has(routeId),
)
const busRoutes = routeIds.filter((routeId) => routeType(routeId) === 3)
const stopIds = stops.map((stop) => stop.id)
const subwayStopIds = stops.filter((stop) => stop.mode === "subway").map((stop) => stop.id)
const busStopIds = stops.filter((stop) => stop.mode === "bus").map((stop) => stop.id)

async function loadBusGtfsRoutes(urls: string[]) {
const routes: SnapshotRoute[] = []

await Promise.all(
urls.map(async (url) => {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Unable to fetch bus GTFS routes from ${url}: ${response.status} ${response.statusText}`)
}

const files = unzipSync(new Uint8Array(await response.arrayBuffer()))
const rows = parseGtfsFile<GtfsRoute>(files, "routes.txt")

routes.push(
...rows.map((route) => ({
id: route.route_id,
shortName: route.route_short_name || route.route_id,
longName: route.route_long_name || undefined,
type: Number(route.route_type || 3),
color: route.route_color || undefined,
textColor: route.route_text_color || undefined,
})),
)
}),
)

return routes
}

function parseGtfsFile<T>(files: Record<string, Uint8Array>, name: string) {
const bytes = files[name]
if (!bytes) return []

return parse(new TextDecoder().decode(bytes), {
bom: true,
columns: true,
skip_empty_lines: true,
}) as T[]
}

function uniqueSorted(values: string[]) {
return [...new Set(values)].sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
}

function union(name: string, values: string[]) {
const sorted = uniqueSorted(values)
if (!sorted.length) return `export type ${name} = never;\n`

return [
`export type ${name} =`,
...sorted.map((value, index) => ` ${index === 0 ? "" : "| "}${JSON.stringify(value)}`),
].join("\n") + "\n"
}

const generated = `// Generated by scripts/generate-known-types.ts from the hosted stops snapshot.\n// Snapshot generated at: ${snapshot.generatedAt ?? "unknown"}\n// Do not edit by hand.\n\n${union("KnownRoute", routeIds)}\n${union("KnownSubwayRoute", subwayRoutes)}\n${union("KnownBusRoute", busRoutes)}\n${union("KnownStopId", stopIds)}\n${union("KnownSubwayStopId", subwayStopIds)}\n${union("KnownBusStopId", busStopIds)}`

await Bun.write(new URL("../src/generated.ts", import.meta.url), generated)
console.log(
`Generated src/generated.ts with ${uniqueSorted(routeIds).length} routes and ${uniqueSorted(stopIds).length} stops.`,
)
Loading
Loading