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 +43 -0
- package/index.ts +72 -1
- package/package.json +1 -1
- package/src/http.ts +6 -3
- package/src/types.ts +12 -0
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<
|
|
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
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(
|
|
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
|
}
|