mta-js 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,41 @@ await mta.alerts.current({ mode: "subway" });
16
16
  await mta.stops.near({ lat, lon, modes: ["subway", "bus"] });
17
17
  ```
18
18
 
19
+ ## Hosted API
20
+
21
+ Pass an `apiKey` to use the hosted MTA API instead of calling MTA feeds and local
22
+ static GTFS directly. The public method names stay the same, but requests are sent
23
+ to `https://www.mtaapi.dev/api/v1` with the key attached.
24
+
25
+ ```ts
26
+ import { MTA } from "mta-js";
27
+
28
+ const mta = new MTA({
29
+ apiKey: process.env.MTA_API_KEY,
30
+ });
31
+
32
+ await mta.subway.arrivals({ stopId: "A27", route: "A" });
33
+ await mta.bus.arrivals({ stopId: "308214", route: "M23" });
34
+ await mta.bus.vehicles({ route: "M23", limit: 5 });
35
+ await mta.alerts.current({ mode: "subway" });
36
+ await mta.stops.near({
37
+ lat: 40.7356,
38
+ lon: -73.9804,
39
+ modes: ["subway", "bus"],
40
+ route: "M23",
41
+ includeRoutes: true,
42
+ });
43
+ ```
44
+
45
+ Use `apiBaseUrl` to point at a preview, staging, or self-hosted compatible API:
46
+
47
+ ```ts
48
+ const mta = new MTA({
49
+ apiKey: process.env.MTA_API_KEY,
50
+ apiBaseUrl: "https://staging.example.com",
51
+ });
52
+ ```
53
+
19
54
  ## Database
20
55
 
21
56
  Realtime feeds only become useful after they are joined back to static GTFS stops, routes, and trips. Today, `databaseUrl` points to a SQLite database used by `bun:sqlite`.
@@ -315,6 +350,14 @@ mta.static.importSeed(
315
350
  );
316
351
  ```
317
352
 
353
+ ## Roadmap
354
+
355
+ - Expand the package beyond NYC MTA into a normalized US metro SDK while keeping the existing MTA API stable.
356
+ - Prioritize large systems with documented APIs and realtime data first: MBTA (Boston), WMATA (DC), CTA (Chicago), and BART (Bay Area).
357
+ - Add adapters for mid-tier systems that expose GTFS, GTFS Realtime, or partial APIs, including SEPTA, LA Metro, and MARTA.
358
+ - Model agency-specific quirks behind shared concepts like arrivals, vehicles, service alerts, route-aware nearby stops, static GTFS import, and hosted API access.
359
+ - Track smaller agencies separately because support quality varies: some only publish static GTFS, some have buried realtime feeds, and some have no public API surface.
360
+
318
361
  ## Development
319
362
 
320
363
  ```sh
package/index.ts CHANGED
@@ -21,6 +21,7 @@ import type {
21
21
  GtfsImportSummary,
22
22
  MTAEndpoints,
23
23
  MTAOptions,
24
+ NearbyStop,
24
25
  Route,
25
26
  Stop,
26
27
  StopsNearQuery,
@@ -41,6 +42,8 @@ export class MTA {
41
42
 
42
43
  readonly fetch: typeof fetch;
43
44
  readonly now: () => Date;
45
+ readonly apiKey?: string;
46
+ readonly apiBaseUrl: string;
44
47
  readonly busTimeKey?: string;
45
48
  readonly endpoints: MTAEndpoints;
46
49
  readonly options: MTAOptions;
@@ -52,6 +55,8 @@ export class MTA {
52
55
  this.options = options;
53
56
  this.fetch = options.fetch ?? fetch;
54
57
  this.now = options.now ?? (() => new Date());
58
+ this.apiKey = options.apiKey;
59
+ this.apiBaseUrl = options.apiBaseUrl ?? "https://www.mtaapi.dev";
55
60
  this.busTimeKey = options.busTimeKey;
56
61
  this.realtimeCacheTtlMs = options.realtimeCacheTtlMs ?? 15_000;
57
62
  this.endpoints = {
@@ -114,6 +119,28 @@ export class MTA {
114
119
  }
115
120
  return feed;
116
121
  }
122
+
123
+ hostedApiEnabled() {
124
+ return Boolean(this.apiKey);
125
+ }
126
+
127
+ async hostedJson<T>(path: string, query: object = {}): Promise<T> {
128
+ if (!this.apiKey) {
129
+ throw new Error("mta-js hosted API calls require an apiKey.");
130
+ }
131
+
132
+ const url = urlWithParams(
133
+ new URL(path, this.apiBaseUrl).toString(),
134
+ serializeHostedQuery(query),
135
+ );
136
+
137
+ return fetchJson(this.fetch, url, {
138
+ headers: {
139
+ Authorization: `Bearer ${this.apiKey}`,
140
+ "x-api-key": this.apiKey,
141
+ },
142
+ }) as Promise<T>;
143
+ }
117
144
  }
118
145
 
119
146
  class DatabaseClient {
@@ -135,6 +162,10 @@ class DatabaseClient {
135
162
  }
136
163
 
137
164
  async status(): Promise<DatabaseStatus> {
165
+ if (this.mta.hostedApiEnabled()) {
166
+ return this.mta.hostedJson<DatabaseStatus>("/api/v1/database/status");
167
+ }
168
+
138
169
  await this.mta.ready();
139
170
  return this.mta.static.status();
140
171
  }
@@ -206,6 +237,10 @@ class SubwayClient {
206
237
  limit?: number;
207
238
  includeRaw?: boolean;
208
239
  }): Promise<Arrival[]> {
240
+ if (this.mta.hostedApiEnabled()) {
241
+ return this.mta.hostedJson<Arrival[]>("/api/v1/subway/arrivals", query);
242
+ }
243
+
209
244
  await this.mta.ready();
210
245
  const routeIds = query.route ? [normalizeRouteId(query.route)] : Object.keys(this.mta.endpoints.subwayFeeds);
211
246
  const feeds = [...new Set(routeIds.map((route) => this.feedForRoute(route)))];
@@ -288,6 +323,10 @@ class BusClient {
288
323
  constructor(private readonly mta: MTA) {}
289
324
 
290
325
  async arrivals(query: BusArrivalQuery): Promise<Arrival[]> {
326
+ if (this.mta.hostedApiEnabled()) {
327
+ return this.mta.hostedJson<Arrival[]>("/api/v1/bus/arrivals", query);
328
+ }
329
+
291
330
  await this.mta.ready();
292
331
  const key = this.requireKey();
293
332
  const body = await fetchJson(
@@ -332,6 +371,10 @@ class BusClient {
332
371
  }
333
372
 
334
373
  async vehicles(query: BusVehicleQuery = {}): Promise<Vehicle[]> {
374
+ if (this.mta.hostedApiEnabled()) {
375
+ return this.mta.hostedJson<Vehicle[]>("/api/v1/bus/vehicles", query);
376
+ }
377
+
335
378
  await this.mta.ready();
336
379
  const key = this.requireKey();
337
380
  const body = await fetchJson(
@@ -378,6 +421,10 @@ class AlertsClient {
378
421
  constructor(private readonly mta: MTA) {}
379
422
 
380
423
  async current(query: AlertQuery = {}): Promise<Alert[]> {
424
+ if (this.mta.hostedApiEnabled()) {
425
+ return this.mta.hostedJson<Alert[]>("/api/v1/alerts", query);
426
+ }
427
+
381
428
  await this.mta.ready();
382
429
  const feed = await this.mta.realtimeFeed(this.mta.endpoints.alerts);
383
430
  const alerts: Alert[] = [];
@@ -419,7 +466,11 @@ class AlertsClient {
419
466
  class StopsClient {
420
467
  constructor(private readonly mta: MTA) {}
421
468
 
422
- near(query: StopsNearQuery): Promise<Stop[]> {
469
+ near(query: StopsNearQuery): Promise<NearbyStop[]> {
470
+ if (this.mta.hostedApiEnabled()) {
471
+ return this.mta.hostedJson<NearbyStop[]>("/api/v1/stops/near", query);
472
+ }
473
+
423
474
  return this.mta.ready().then(() => {
424
475
  for (const mode of query.modes ?? []) {
425
476
  if (!this.mta.static.hasStaticData(mode)) throw new StaticDataMissingError(mode);
@@ -429,6 +480,26 @@ class StopsClient {
429
480
  }
430
481
  }
431
482
 
483
+ function serializeHostedQuery(query: object) {
484
+ const params: Record<string, string | number | boolean | undefined> = {};
485
+ for (const [key, value] of Object.entries(query)) {
486
+ if (
487
+ value === undefined ||
488
+ typeof value === "string" ||
489
+ typeof value === "number" ||
490
+ typeof value === "boolean"
491
+ ) {
492
+ params[key] = value;
493
+ continue;
494
+ }
495
+
496
+ if (Array.isArray(value)) {
497
+ params[key] = value.join(",");
498
+ }
499
+ }
500
+ return params;
501
+ }
502
+
432
503
  function normalizeRouteId(route: string) {
433
504
  return route.toUpperCase().trim();
434
505
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mta-js",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "A TypeScript client for MTA realtime and static GTFS data.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/http.ts CHANGED
@@ -8,15 +8,18 @@ export async function fetchArrayBuffer(fetchImpl: typeof fetch, url: string) {
8
8
  return response.arrayBuffer();
9
9
  }
10
10
 
11
- export async function fetchJson(fetchImpl: typeof fetch, url: string) {
12
- const response = await fetchImpl(url);
11
+ export async function fetchJson(fetchImpl: typeof fetch, url: string, init?: RequestInit) {
12
+ const response = await fetchImpl(url, init);
13
13
  if (!response.ok) {
14
14
  throw new FeedError(`MTA API request failed: ${response.status} ${response.statusText}`, response);
15
15
  }
16
16
  return response.json() as Promise<unknown>;
17
17
  }
18
18
 
19
- export function urlWithParams(base: string, params: Record<string, string | number | undefined>) {
19
+ export function urlWithParams(
20
+ base: string,
21
+ params: Record<string, string | number | boolean | undefined>,
22
+ ) {
20
23
  const url = new URL(base);
21
24
  for (const [key, value] of Object.entries(params)) {
22
25
  if (value !== undefined && value !== "") {
package/src/types.ts CHANGED
@@ -3,6 +3,8 @@ export type TransitMode = "subway" | "bus" | "lirr" | "metro-north";
3
3
  export type Direction = "north" | "south" | "east" | "west" | "unknown";
4
4
 
5
5
  export interface MTAOptions {
6
+ apiKey?: string;
7
+ apiBaseUrl?: string;
6
8
  busTimeKey?: string;
7
9
  databaseUrl?: string;
8
10
  databaseAuthToken?: string;
@@ -118,6 +120,14 @@ export interface Stop {
118
120
  mode?: TransitMode;
119
121
  }
120
122
 
123
+ export type NearbyStop = Stop & {
124
+ distanceMeters?: number;
125
+ servedRoutes?: Route[];
126
+ routeMatch?: boolean;
127
+ routeHeadsigns?: string[];
128
+ note?: string;
129
+ };
130
+
121
131
  export interface Arrival {
122
132
  mode: TransitMode;
123
133
  route: Route;
@@ -196,6 +206,8 @@ export interface StopsNearQuery {
196
206
  lat: number;
197
207
  lon: number;
198
208
  modes?: TransitMode[];
209
+ route?: string;
210
+ includeRoutes?: boolean;
199
211
  radiusMeters?: number;
200
212
  limit?: number;
201
213
  }