| 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.`, | ||
| ) |
Sorry, the diff of this file is too big to display
+4
-8
@@ -19,2 +19,3 @@ import { defaultEndpoints, subwayRouteColors } from "./src/defaults"; | ||
| StopsNearQuery, | ||
| SubwayArrivalQuery, | ||
| TransitMode, | ||
@@ -111,9 +112,3 @@ Vehicle, | ||
| 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()) { | ||
@@ -151,3 +146,3 @@ return this.mta.hostedJson<Arrival[]>("/api/v1/subway/arrivals", query); | ||
| stopIds: Set<string>, | ||
| query: { stopId: string; route?: string; direction?: Direction | "uptown" | "downtown"; includeRaw?: boolean }, | ||
| query: SubwayArrivalQuery, | ||
| ) { | ||
@@ -487,2 +482,3 @@ const arrivals: Arrival[] = []; | ||
| export * from "./src/errors"; | ||
| export type * from "./src/generated"; | ||
| export type * from "./src/types"; |
+3
-1
| { | ||
| "name": "mta-js", | ||
| "version": "2.0.0", | ||
| "version": "2.1.0", | ||
| "description": "A TypeScript client for MTA realtime feeds and the hosted MTA API.", | ||
@@ -13,2 +13,3 @@ "license": "MIT", | ||
| "src", | ||
| "scripts", | ||
| "examples", | ||
@@ -24,2 +25,3 @@ "README.md" | ||
| "scripts": { | ||
| "generate:types": "bun scripts/generate-known-types.ts", | ||
| "test": "bun test", | ||
@@ -26,0 +28,0 @@ "typecheck": "bunx tsc --noEmit" |
+8
-0
@@ -80,2 +80,10 @@ # mta-js | ||
| 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 | ||
@@ -82,0 +90,0 @@ |
+28
-8
@@ -0,1 +1,21 @@ | ||
| import type { | ||
| KnownBusRoute, | ||
| KnownBusStopId, | ||
| KnownRoute, | ||
| KnownStopId, | ||
| KnownSubwayRoute, | ||
| KnownSubwayStopId, | ||
| } from "./generated"; | ||
| export type AutocompleteString<TKnown extends string> = | ||
| | TKnown | ||
| | (string & {}); | ||
| export type RouteId = AutocompleteString<KnownRoute>; | ||
| export type SubwayRoute = AutocompleteString<KnownSubwayRoute>; | ||
| export type BusRoute = AutocompleteString<KnownBusRoute>; | ||
| export type StopId = AutocompleteString<KnownStopId>; | ||
| export type SubwayStopId = AutocompleteString<KnownSubwayStopId>; | ||
| export type BusStopId = AutocompleteString<KnownBusStopId>; | ||
| export type TransitMode = "subway" | "bus" | "lirr" | "metro-north"; | ||
@@ -174,4 +194,4 @@ | ||
| export interface SubwayArrivalQuery { | ||
| stopId: string; | ||
| route?: string; | ||
| stopId: SubwayStopId; | ||
| route?: SubwayRoute; | ||
| direction?: Direction | "uptown" | "downtown"; | ||
@@ -183,4 +203,4 @@ limit?: number; | ||
| export interface BusArrivalQuery { | ||
| stopId: string; | ||
| route?: string; | ||
| stopId: BusStopId; | ||
| route?: BusRoute; | ||
| limit?: number; | ||
@@ -191,3 +211,3 @@ includeRaw?: boolean; | ||
| export interface BusVehicleQuery { | ||
| route?: string; | ||
| route?: BusRoute; | ||
| vehicleId?: string; | ||
@@ -200,4 +220,4 @@ limit?: number; | ||
| mode?: TransitMode; | ||
| route?: string; | ||
| stopId?: string; | ||
| route?: RouteId; | ||
| stopId?: StopId; | ||
| includeRaw?: boolean; | ||
@@ -210,3 +230,3 @@ } | ||
| modes?: TransitMode[]; | ||
| route?: string; | ||
| route?: RouteId; | ||
| includeRoutes?: boolean; | ||
@@ -213,0 +233,0 @@ radiusMeters?: number; |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 4 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
137256
223.93%12
20%8847
619.85%95
9.2%7
133.33%18
20%