mta-js 1.0.0
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 +324 -0
- package/examples/vercel-route.ts +26 -0
- package/index.ts +585 -0
- package/package.json +42 -0
- package/src/cli.ts +107 -0
- package/src/database-url.ts +238 -0
- package/src/defaults.ts +65 -0
- package/src/errors.ts +43 -0
- package/src/gtfs-realtime.ts +245 -0
- package/src/http.ts +27 -0
- package/src/schema.ts +51 -0
- package/src/static-gtfs.ts +400 -0
- package/src/types.ts +201 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { MTA } from "../index";
|
|
3
|
+
import { defaultStaticGtfsUrls } from "./defaults";
|
|
4
|
+
import type { StaticGtfsImportStrategy, TransitMode } from "./types";
|
|
5
|
+
|
|
6
|
+
const args = Bun.argv.slice(2);
|
|
7
|
+
|
|
8
|
+
const command = args.slice(0, 2).join(" ");
|
|
9
|
+
if (command !== "db push" && command !== "db import") {
|
|
10
|
+
usage();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const options = Object.fromEntries(
|
|
14
|
+
args.slice(2).map((arg) => {
|
|
15
|
+
const [key, ...value] = arg.replace(/^--/, "").split("=");
|
|
16
|
+
return [key, value.join("=") || "true"];
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const databaseUrl =
|
|
21
|
+
options["database-url"] ??
|
|
22
|
+
process.env.MTA_DATABASE_URL ??
|
|
23
|
+
process.env.TURSO_DATABASE_URL ??
|
|
24
|
+
process.env.DATABASE_URL;
|
|
25
|
+
const databaseAuthToken =
|
|
26
|
+
options["database-auth-token"] ??
|
|
27
|
+
process.env.MTA_DATABASE_AUTH_TOKEN ??
|
|
28
|
+
process.env.TURSO_AUTH_TOKEN;
|
|
29
|
+
const databaseLocalPath = options["database-local-path"] ?? process.env.MTA_DATABASE_LOCAL_PATH;
|
|
30
|
+
|
|
31
|
+
if (!databaseUrl) {
|
|
32
|
+
console.error("Missing database URL. Pass --database-url or set MTA_DATABASE_URL/TURSO_DATABASE_URL/DATABASE_URL.");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mta = new MTA({
|
|
37
|
+
databaseUrl,
|
|
38
|
+
databaseAuthToken,
|
|
39
|
+
databaseLocalPath,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
if (command === "db push") {
|
|
44
|
+
const result = await mta.database.push();
|
|
45
|
+
console.log(`Pushed GTFS schema (${result.statements} statements${result.remote ? ", remote" : ", local"}).`);
|
|
46
|
+
} else {
|
|
47
|
+
const mode = parseMode(options.mode);
|
|
48
|
+
const strategy = parseStrategy(options.strategy);
|
|
49
|
+
const sourceUrl = options["source-url"] ?? defaultSourceUrl(mode);
|
|
50
|
+
if (!sourceUrl) {
|
|
51
|
+
throw new Error(`No default GTFS source URL for mode ${mode}. Pass --source-url.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const summary = await mta.database.importStaticData({
|
|
55
|
+
mode,
|
|
56
|
+
sourceUrl,
|
|
57
|
+
strategy,
|
|
58
|
+
});
|
|
59
|
+
if (!summary) {
|
|
60
|
+
throw new Error("Import completed but no local summary was available. Rehydrate the database and check gtfs_imports.");
|
|
61
|
+
}
|
|
62
|
+
console.log(
|
|
63
|
+
[
|
|
64
|
+
`Imported ${summary.mode} GTFS (${strategy})`,
|
|
65
|
+
`source=${summary.sourceUrl ?? "unknown"}`,
|
|
66
|
+
`stops=${summary.stopCount}`,
|
|
67
|
+
`routes=${summary.routeCount}`,
|
|
68
|
+
`trips=${summary.tripCount}`,
|
|
69
|
+
`stop_times=${summary.stopTimeCount}`,
|
|
70
|
+
].join(" "),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
mta.close();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseMode(value: string | undefined): TransitMode {
|
|
78
|
+
const mode = (value ?? "subway") as TransitMode;
|
|
79
|
+
if (!["subway", "bus", "lirr", "metro-north"].includes(mode)) {
|
|
80
|
+
throw new Error(`Unsupported mode: ${value}`);
|
|
81
|
+
}
|
|
82
|
+
return mode;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseStrategy(value: string | undefined): StaticGtfsImportStrategy {
|
|
86
|
+
const strategy = (value ?? "core") as StaticGtfsImportStrategy;
|
|
87
|
+
if (!["core", "schedule"].includes(strategy)) {
|
|
88
|
+
throw new Error(`Unsupported import strategy: ${value}`);
|
|
89
|
+
}
|
|
90
|
+
return strategy;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function defaultSourceUrl(mode: TransitMode) {
|
|
94
|
+
if (mode === "subway") return defaultStaticGtfsUrls.subway;
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function usage(): never {
|
|
99
|
+
console.error(
|
|
100
|
+
[
|
|
101
|
+
"Usage:",
|
|
102
|
+
" mta-js db push --database-url=<url> [--database-auth-token=<token>]",
|
|
103
|
+
" mta-js db import --mode=subway [--strategy=core|schedule] [--source-url=<url>]",
|
|
104
|
+
].join("\n"),
|
|
105
|
+
);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { createClient, type Client, type InStatement } from "@libsql/client";
|
|
3
|
+
import { gtfsSchemaStatements } from "./schema";
|
|
4
|
+
import type { StaticGtfsSeed, TransitMode } from "./types";
|
|
5
|
+
|
|
6
|
+
export function resolveSqliteDatabaseUrl(databaseUrl: string | undefined) {
|
|
7
|
+
if (!databaseUrl) return undefined;
|
|
8
|
+
if (isRemoteDatabaseUrl(databaseUrl)) return undefined;
|
|
9
|
+
if (databaseUrl === ":memory:") return databaseUrl;
|
|
10
|
+
if (!databaseUrl.startsWith("file:")) return databaseUrl;
|
|
11
|
+
|
|
12
|
+
const url = new URL(databaseUrl);
|
|
13
|
+
return decodeURIComponent(url.pathname);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function hydrateRemoteDatabaseUrl(options: {
|
|
17
|
+
databaseUrl: string | undefined;
|
|
18
|
+
databaseAuthToken?: string;
|
|
19
|
+
databaseLocalPath?: string;
|
|
20
|
+
fetch: typeof fetch;
|
|
21
|
+
refresh?: boolean;
|
|
22
|
+
}) {
|
|
23
|
+
if (!options.databaseUrl || !isRemoteDatabaseUrl(options.databaseUrl)) {
|
|
24
|
+
return options.databaseUrl;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const localPath = resolveRemoteDatabaseLocalPath(options.databaseUrl, options.databaseLocalPath);
|
|
28
|
+
const existing = Bun.file(localPath);
|
|
29
|
+
if (!options.refresh && await existing.exists()) {
|
|
30
|
+
return localPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (isLibsqlDatabaseUrl(options.databaseUrl)) {
|
|
34
|
+
await hydrateLibsqlDatabase({
|
|
35
|
+
databaseUrl: options.databaseUrl,
|
|
36
|
+
databaseAuthToken: options.databaseAuthToken,
|
|
37
|
+
localPath,
|
|
38
|
+
});
|
|
39
|
+
return localPath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const response = await options.fetch(options.databaseUrl);
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`Failed to hydrate databaseUrl ${options.databaseUrl}: ${response.status} ${response.statusText}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
mkdirSync(localPath.slice(0, localPath.lastIndexOf("/")), { recursive: true });
|
|
48
|
+
await Bun.write(localPath, response);
|
|
49
|
+
return localPath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function pushRemoteDatabaseSchema(options: {
|
|
53
|
+
databaseUrl: string | undefined;
|
|
54
|
+
databaseAuthToken?: string;
|
|
55
|
+
}) {
|
|
56
|
+
if (!options.databaseUrl || !isLibsqlDatabaseUrl(options.databaseUrl)) {
|
|
57
|
+
return { remote: false, statements: gtfsSchemaStatements.length };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const client = createClient({
|
|
61
|
+
url: options.databaseUrl,
|
|
62
|
+
authToken: options.databaseAuthToken,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
for (const sql of gtfsSchemaStatements) {
|
|
67
|
+
await client.execute(sql);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
client.close();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { remote: true, statements: gtfsSchemaStatements.length };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function importRemoteStaticSeed(options: {
|
|
77
|
+
databaseUrl: string | undefined;
|
|
78
|
+
databaseAuthToken?: string;
|
|
79
|
+
seed: StaticGtfsSeed;
|
|
80
|
+
mode: TransitMode;
|
|
81
|
+
sourceUrl?: string;
|
|
82
|
+
}) {
|
|
83
|
+
if (!options.databaseUrl || !isLibsqlDatabaseUrl(options.databaseUrl)) {
|
|
84
|
+
return { remote: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const client = createClient({
|
|
88
|
+
url: options.databaseUrl,
|
|
89
|
+
authToken: options.databaseAuthToken,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await batchChunks(client, gtfsSchemaStatements.map((sql) => ({ sql, args: [] })));
|
|
94
|
+
await batchChunks(
|
|
95
|
+
client,
|
|
96
|
+
(options.seed.stops ?? []).map((stop) => ({
|
|
97
|
+
sql: `insert or replace into stops
|
|
98
|
+
(id, name, lat, lon, parent_station, location_type, mode)
|
|
99
|
+
values (?, ?, ?, ?, ?, ?, ?)`,
|
|
100
|
+
args: [
|
|
101
|
+
stop.stop_id,
|
|
102
|
+
stop.stop_name,
|
|
103
|
+
numberOrNull(stop.stop_lat),
|
|
104
|
+
numberOrNull(stop.stop_lon),
|
|
105
|
+
stop.parent_station ?? null,
|
|
106
|
+
numberOrNull(stop.location_type),
|
|
107
|
+
options.mode,
|
|
108
|
+
],
|
|
109
|
+
})),
|
|
110
|
+
);
|
|
111
|
+
await batchChunks(
|
|
112
|
+
client,
|
|
113
|
+
(options.seed.routes ?? []).map((route) => ({
|
|
114
|
+
sql: `insert or replace into routes
|
|
115
|
+
(id, short_name, long_name, type, color, text_color)
|
|
116
|
+
values (?, ?, ?, ?, ?, ?)`,
|
|
117
|
+
args: [
|
|
118
|
+
route.route_id,
|
|
119
|
+
route.route_short_name ?? route.route_id,
|
|
120
|
+
route.route_long_name ?? null,
|
|
121
|
+
numberOrNull(route.route_type),
|
|
122
|
+
normalizeColor(route.route_color),
|
|
123
|
+
normalizeColor(route.route_text_color),
|
|
124
|
+
],
|
|
125
|
+
})),
|
|
126
|
+
);
|
|
127
|
+
await batchChunks(
|
|
128
|
+
client,
|
|
129
|
+
(options.seed.trips ?? []).map((trip) => ({
|
|
130
|
+
sql: `insert or replace into trips
|
|
131
|
+
(id, route_id, service_id, headsign, direction_id)
|
|
132
|
+
values (?, ?, ?, ?, ?)`,
|
|
133
|
+
args: [
|
|
134
|
+
trip.trip_id,
|
|
135
|
+
trip.route_id,
|
|
136
|
+
trip.service_id ?? null,
|
|
137
|
+
trip.trip_headsign ?? null,
|
|
138
|
+
numberOrNull(trip.direction_id),
|
|
139
|
+
],
|
|
140
|
+
})),
|
|
141
|
+
);
|
|
142
|
+
await batchChunks(
|
|
143
|
+
client,
|
|
144
|
+
(options.seed.stopTimes ?? []).map((stopTime) => ({
|
|
145
|
+
sql: `insert or replace into stop_times
|
|
146
|
+
(trip_id, arrival_time, departure_time, stop_id, stop_sequence)
|
|
147
|
+
values (?, ?, ?, ?, ?)`,
|
|
148
|
+
args: [
|
|
149
|
+
stopTime.trip_id,
|
|
150
|
+
stopTime.arrival_time ?? null,
|
|
151
|
+
stopTime.departure_time ?? null,
|
|
152
|
+
stopTime.stop_id,
|
|
153
|
+
numberOrNull(stopTime.stop_sequence) ?? 0,
|
|
154
|
+
],
|
|
155
|
+
})),
|
|
156
|
+
);
|
|
157
|
+
await client.batch(
|
|
158
|
+
[
|
|
159
|
+
{
|
|
160
|
+
sql: `insert or replace into gtfs_imports
|
|
161
|
+
(mode, imported_at, source_url, stop_count, route_count, trip_count, stop_time_count)
|
|
162
|
+
values (?, ?, ?, ?, ?, ?, ?)`,
|
|
163
|
+
args: [
|
|
164
|
+
options.mode,
|
|
165
|
+
new Date().toISOString(),
|
|
166
|
+
options.sourceUrl ?? null,
|
|
167
|
+
options.seed.stops?.length ?? 0,
|
|
168
|
+
options.seed.routes?.length ?? 0,
|
|
169
|
+
options.seed.trips?.length ?? 0,
|
|
170
|
+
options.seed.stopTimes?.length ?? 0,
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
"write",
|
|
175
|
+
);
|
|
176
|
+
} finally {
|
|
177
|
+
client.close();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { remote: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function isRemoteDatabaseUrl(databaseUrl: string) {
|
|
184
|
+
return isHttpDatabaseUrl(databaseUrl) || isLibsqlDatabaseUrl(databaseUrl);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function resolveRemoteDatabaseLocalPath(databaseUrl: string, databaseLocalPath?: string) {
|
|
188
|
+
return databaseLocalPath ?? defaultRemoteDatabasePath(databaseUrl);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function isHttpDatabaseUrl(databaseUrl: string) {
|
|
192
|
+
return databaseUrl.startsWith("https://") || databaseUrl.startsWith("http://");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function isLibsqlDatabaseUrl(databaseUrl: string) {
|
|
196
|
+
return databaseUrl.startsWith("libsql://");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function defaultRemoteDatabasePath(databaseUrl: string) {
|
|
200
|
+
const tmp = process.env.TMPDIR ?? "/tmp";
|
|
201
|
+
const url = new URL(databaseUrl);
|
|
202
|
+
const basename = url.pathname.split("/").filter(Boolean).at(-1) ?? "gtfs.sqlite";
|
|
203
|
+
const hash = Bun.hash(databaseUrl).toString(36);
|
|
204
|
+
return `${tmp.replace(/\/$/, "")}/mta-js/${hash}-${basename}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function hydrateLibsqlDatabase(options: {
|
|
208
|
+
databaseUrl: string;
|
|
209
|
+
databaseAuthToken?: string;
|
|
210
|
+
localPath: string;
|
|
211
|
+
}) {
|
|
212
|
+
mkdirSync(options.localPath.slice(0, options.localPath.lastIndexOf("/")), { recursive: true });
|
|
213
|
+
const client = createClient({
|
|
214
|
+
url: `file:${options.localPath}`,
|
|
215
|
+
syncUrl: options.databaseUrl,
|
|
216
|
+
authToken: options.databaseAuthToken,
|
|
217
|
+
});
|
|
218
|
+
await client.sync();
|
|
219
|
+
client.close();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function numberOrNull(value: unknown) {
|
|
223
|
+
if (value === undefined || value === null || value === "") return null;
|
|
224
|
+
const number = Number(value);
|
|
225
|
+
return Number.isFinite(number) ? number : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeColor(value: string | undefined) {
|
|
229
|
+
if (!value) return null;
|
|
230
|
+
return value.startsWith("#") ? value : `#${value}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function batchChunks(client: Client, statements: InStatement[], size = 500) {
|
|
234
|
+
for (let index = 0; index < statements.length; index += size) {
|
|
235
|
+
const chunk = statements.slice(index, index + size);
|
|
236
|
+
if (chunk.length) await client.batch(chunk, "write");
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { MTAEndpoints } from "./types";
|
|
2
|
+
|
|
3
|
+
const realtimeBase = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds";
|
|
4
|
+
|
|
5
|
+
export const defaultEndpoints: MTAEndpoints = {
|
|
6
|
+
subwayFeeds: {
|
|
7
|
+
"1": `${realtimeBase}/nyct%2Fgtfs`,
|
|
8
|
+
"2": `${realtimeBase}/nyct%2Fgtfs`,
|
|
9
|
+
"3": `${realtimeBase}/nyct%2Fgtfs`,
|
|
10
|
+
"4": `${realtimeBase}/nyct%2Fgtfs`,
|
|
11
|
+
"5": `${realtimeBase}/nyct%2Fgtfs`,
|
|
12
|
+
"6": `${realtimeBase}/nyct%2Fgtfs`,
|
|
13
|
+
"7": `${realtimeBase}/nyct%2Fgtfs`,
|
|
14
|
+
A: `${realtimeBase}/nyct%2Fgtfs-ace`,
|
|
15
|
+
C: `${realtimeBase}/nyct%2Fgtfs-ace`,
|
|
16
|
+
E: `${realtimeBase}/nyct%2Fgtfs-ace`,
|
|
17
|
+
B: `${realtimeBase}/nyct%2Fgtfs-bdfm`,
|
|
18
|
+
D: `${realtimeBase}/nyct%2Fgtfs-bdfm`,
|
|
19
|
+
F: `${realtimeBase}/nyct%2Fgtfs-bdfm`,
|
|
20
|
+
M: `${realtimeBase}/nyct%2Fgtfs-bdfm`,
|
|
21
|
+
G: `${realtimeBase}/nyct%2Fgtfs-g`,
|
|
22
|
+
J: `${realtimeBase}/nyct%2Fgtfs-jz`,
|
|
23
|
+
Z: `${realtimeBase}/nyct%2Fgtfs-jz`,
|
|
24
|
+
L: `${realtimeBase}/nyct%2Fgtfs-l`,
|
|
25
|
+
N: `${realtimeBase}/nyct%2Fgtfs-nqrw`,
|
|
26
|
+
Q: `${realtimeBase}/nyct%2Fgtfs-nqrw`,
|
|
27
|
+
R: `${realtimeBase}/nyct%2Fgtfs-nqrw`,
|
|
28
|
+
W: `${realtimeBase}/nyct%2Fgtfs-nqrw`,
|
|
29
|
+
SIR: `${realtimeBase}/nyct%2Fgtfs-si`,
|
|
30
|
+
},
|
|
31
|
+
alerts: `${realtimeBase}/camsys%2Fall-alerts`,
|
|
32
|
+
busVehicleMonitoring: "https://bustime.mta.info/api/siri/vehicle-monitoring.json",
|
|
33
|
+
busStopMonitoring: "https://bustime.mta.info/api/siri/stop-monitoring.json",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const defaultStaticGtfsUrls = {
|
|
37
|
+
subway: "https://rrgtfsfeeds.s3.amazonaws.com/gtfs_subway.zip",
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
export const subwayRouteColors: Record<string, string> = {
|
|
41
|
+
"1": "EE352E",
|
|
42
|
+
"2": "EE352E",
|
|
43
|
+
"3": "EE352E",
|
|
44
|
+
"4": "00933C",
|
|
45
|
+
"5": "00933C",
|
|
46
|
+
"6": "00933C",
|
|
47
|
+
"7": "B933AD",
|
|
48
|
+
A: "0039A6",
|
|
49
|
+
C: "0039A6",
|
|
50
|
+
E: "0039A6",
|
|
51
|
+
B: "FF6319",
|
|
52
|
+
D: "FF6319",
|
|
53
|
+
F: "FF6319",
|
|
54
|
+
M: "FF6319",
|
|
55
|
+
G: "6CBE45",
|
|
56
|
+
J: "996633",
|
|
57
|
+
Z: "996633",
|
|
58
|
+
L: "A7A9AC",
|
|
59
|
+
N: "FCCC0A",
|
|
60
|
+
Q: "FCCC0A",
|
|
61
|
+
R: "FCCC0A",
|
|
62
|
+
W: "FCCC0A",
|
|
63
|
+
S: "808183",
|
|
64
|
+
SIR: "006BB6",
|
|
65
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export class MTAError extends Error {
|
|
2
|
+
override name = "MTAError";
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export class MissingBusTimeKeyError extends MTAError {
|
|
6
|
+
override name = "MissingBusTimeKeyError";
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
super("MTA BusTime API calls require a busTimeKey.");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class UnknownRouteError extends MTAError {
|
|
14
|
+
override name = "UnknownRouteError";
|
|
15
|
+
|
|
16
|
+
constructor(route: string) {
|
|
17
|
+
super(`Unknown MTA route: ${route}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class UnknownStopError extends MTAError {
|
|
22
|
+
override name = "UnknownStopError";
|
|
23
|
+
|
|
24
|
+
constructor(stopId: string) {
|
|
25
|
+
super(`Unknown MTA stop: ${stopId}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class StaticDataMissingError extends MTAError {
|
|
30
|
+
override name = "StaticDataMissingError";
|
|
31
|
+
|
|
32
|
+
constructor(mode: string) {
|
|
33
|
+
super(`Static GTFS data for ${mode} is missing. Run mta.database.importStaticData or the db import CLI before using this lookup.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class FeedError extends MTAError {
|
|
38
|
+
override name = "FeedError";
|
|
39
|
+
|
|
40
|
+
constructor(message: string, readonly response?: Response) {
|
|
41
|
+
super(message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import protobuf from "protobufjs";
|
|
2
|
+
|
|
3
|
+
const proto = `
|
|
4
|
+
syntax = "proto2";
|
|
5
|
+
package transit_realtime;
|
|
6
|
+
|
|
7
|
+
message FeedMessage {
|
|
8
|
+
required FeedHeader header = 1;
|
|
9
|
+
repeated FeedEntity entity = 2;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
message FeedHeader {
|
|
13
|
+
required string gtfs_realtime_version = 1;
|
|
14
|
+
optional Incrementality incrementality = 2 [default = FULL_DATASET];
|
|
15
|
+
optional uint64 timestamp = 3;
|
|
16
|
+
|
|
17
|
+
enum Incrementality {
|
|
18
|
+
FULL_DATASET = 0;
|
|
19
|
+
DIFFERENTIAL = 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
message FeedEntity {
|
|
24
|
+
required string id = 1;
|
|
25
|
+
optional bool is_deleted = 2 [default = false];
|
|
26
|
+
optional TripUpdate trip_update = 3;
|
|
27
|
+
optional VehiclePosition vehicle = 4;
|
|
28
|
+
optional Alert alert = 5;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
message TripUpdate {
|
|
32
|
+
optional TripDescriptor trip = 1;
|
|
33
|
+
optional VehicleDescriptor vehicle = 3;
|
|
34
|
+
repeated StopTimeUpdate stop_time_update = 2;
|
|
35
|
+
optional uint64 timestamp = 4;
|
|
36
|
+
optional int32 delay = 5;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
message StopTimeEvent {
|
|
40
|
+
optional int32 delay = 1;
|
|
41
|
+
optional int64 time = 2;
|
|
42
|
+
optional int32 uncertainty = 3;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
message StopTimeUpdate {
|
|
46
|
+
optional uint32 stop_sequence = 1;
|
|
47
|
+
optional string stop_id = 4;
|
|
48
|
+
optional StopTimeEvent arrival = 2;
|
|
49
|
+
optional StopTimeEvent departure = 3;
|
|
50
|
+
optional ScheduleRelationship schedule_relationship = 5 [default = SCHEDULED];
|
|
51
|
+
|
|
52
|
+
enum ScheduleRelationship {
|
|
53
|
+
SCHEDULED = 0;
|
|
54
|
+
SKIPPED = 1;
|
|
55
|
+
NO_DATA = 2;
|
|
56
|
+
UNSCHEDULED = 3;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
message VehiclePosition {
|
|
61
|
+
optional TripDescriptor trip = 1;
|
|
62
|
+
optional VehicleDescriptor vehicle = 8;
|
|
63
|
+
optional Position position = 2;
|
|
64
|
+
optional uint32 current_stop_sequence = 3;
|
|
65
|
+
optional string stop_id = 7;
|
|
66
|
+
optional VehicleStopStatus current_status = 4 [default = IN_TRANSIT_TO];
|
|
67
|
+
optional uint64 timestamp = 5;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
message Alert {
|
|
71
|
+
repeated TimeRange active_period = 1;
|
|
72
|
+
repeated EntitySelector informed_entity = 5;
|
|
73
|
+
optional Cause cause = 6 [default = UNKNOWN_CAUSE];
|
|
74
|
+
optional Effect effect = 7 [default = UNKNOWN_EFFECT];
|
|
75
|
+
optional TranslatedString url = 8;
|
|
76
|
+
optional TranslatedString header_text = 10;
|
|
77
|
+
optional TranslatedString description_text = 11;
|
|
78
|
+
|
|
79
|
+
enum Cause {
|
|
80
|
+
UNKNOWN_CAUSE = 1;
|
|
81
|
+
OTHER_CAUSE = 2;
|
|
82
|
+
TECHNICAL_PROBLEM = 3;
|
|
83
|
+
STRIKE = 4;
|
|
84
|
+
DEMONSTRATION = 5;
|
|
85
|
+
ACCIDENT = 6;
|
|
86
|
+
HOLIDAY = 7;
|
|
87
|
+
WEATHER = 8;
|
|
88
|
+
MAINTENANCE = 9;
|
|
89
|
+
CONSTRUCTION = 10;
|
|
90
|
+
POLICE_ACTIVITY = 11;
|
|
91
|
+
MEDICAL_EMERGENCY = 12;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
enum Effect {
|
|
95
|
+
NO_SERVICE = 1;
|
|
96
|
+
REDUCED_SERVICE = 2;
|
|
97
|
+
SIGNIFICANT_DELAYS = 3;
|
|
98
|
+
DETOUR = 4;
|
|
99
|
+
ADDITIONAL_SERVICE = 5;
|
|
100
|
+
MODIFIED_SERVICE = 6;
|
|
101
|
+
OTHER_EFFECT = 7;
|
|
102
|
+
UNKNOWN_EFFECT = 8;
|
|
103
|
+
STOP_MOVED = 9;
|
|
104
|
+
NO_EFFECT = 10;
|
|
105
|
+
ACCESSIBILITY_ISSUE = 11;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
message TimeRange {
|
|
110
|
+
optional uint64 start = 1;
|
|
111
|
+
optional uint64 end = 2;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
message Position {
|
|
115
|
+
required float latitude = 1;
|
|
116
|
+
required float longitude = 2;
|
|
117
|
+
optional float bearing = 3;
|
|
118
|
+
optional double odometer = 4;
|
|
119
|
+
optional float speed = 5;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
message TripDescriptor {
|
|
123
|
+
optional string trip_id = 1;
|
|
124
|
+
optional string route_id = 5;
|
|
125
|
+
optional uint32 direction_id = 6;
|
|
126
|
+
optional string start_time = 2;
|
|
127
|
+
optional string start_date = 3;
|
|
128
|
+
optional ScheduleRelationship schedule_relationship = 4;
|
|
129
|
+
|
|
130
|
+
enum ScheduleRelationship {
|
|
131
|
+
SCHEDULED = 0;
|
|
132
|
+
ADDED = 1;
|
|
133
|
+
UNSCHEDULED = 2;
|
|
134
|
+
CANCELED = 3;
|
|
135
|
+
REPLACEMENT = 5;
|
|
136
|
+
DUPLICATED = 6;
|
|
137
|
+
DELETED = 7;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
message VehicleDescriptor {
|
|
142
|
+
optional string id = 1;
|
|
143
|
+
optional string label = 2;
|
|
144
|
+
optional string license_plate = 3;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
message EntitySelector {
|
|
148
|
+
optional string agency_id = 1;
|
|
149
|
+
optional string route_id = 2;
|
|
150
|
+
optional int32 route_type = 3;
|
|
151
|
+
optional TripDescriptor trip = 4;
|
|
152
|
+
optional string stop_id = 5;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
message TranslatedString {
|
|
156
|
+
repeated Translation translation = 1;
|
|
157
|
+
|
|
158
|
+
message Translation {
|
|
159
|
+
required string text = 1;
|
|
160
|
+
optional string language = 2;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
enum VehicleStopStatus {
|
|
165
|
+
INCOMING_AT = 0;
|
|
166
|
+
STOPPED_AT = 1;
|
|
167
|
+
IN_TRANSIT_TO = 2;
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
const root = protobuf.parse(proto).root;
|
|
172
|
+
const FeedMessage = root.lookupType("transit_realtime.FeedMessage");
|
|
173
|
+
|
|
174
|
+
export function decodeFeedMessage(bytes: ArrayBuffer | Uint8Array) {
|
|
175
|
+
const decoded = FeedMessage.decode(new Uint8Array(bytes));
|
|
176
|
+
return FeedMessage.toObject(decoded, {
|
|
177
|
+
longs: Number,
|
|
178
|
+
enums: String,
|
|
179
|
+
defaults: false,
|
|
180
|
+
arrays: true,
|
|
181
|
+
}) as GtfsRealtimeFeed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function encodeFeedMessage(feed: GtfsRealtimeFeed) {
|
|
185
|
+
const message = FeedMessage.fromObject(feed);
|
|
186
|
+
return FeedMessage.encode(message).finish();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface GtfsRealtimeFeed {
|
|
190
|
+
header?: {
|
|
191
|
+
gtfsRealtimeVersion?: string;
|
|
192
|
+
incrementality?: string;
|
|
193
|
+
timestamp?: number;
|
|
194
|
+
};
|
|
195
|
+
entity: GtfsRealtimeEntity[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface GtfsRealtimeEntity {
|
|
199
|
+
id: string;
|
|
200
|
+
isDeleted?: boolean;
|
|
201
|
+
tripUpdate?: {
|
|
202
|
+
trip?: {
|
|
203
|
+
tripId?: string;
|
|
204
|
+
routeId?: string;
|
|
205
|
+
directionId?: number;
|
|
206
|
+
startTime?: string;
|
|
207
|
+
startDate?: string;
|
|
208
|
+
scheduleRelationship?: string;
|
|
209
|
+
};
|
|
210
|
+
vehicle?: {
|
|
211
|
+
id?: string;
|
|
212
|
+
label?: string;
|
|
213
|
+
licensePlate?: string;
|
|
214
|
+
};
|
|
215
|
+
stopTimeUpdate: {
|
|
216
|
+
stopSequence?: number;
|
|
217
|
+
stopId?: string;
|
|
218
|
+
arrival?: { delay?: number; time?: number; uncertainty?: number };
|
|
219
|
+
departure?: { delay?: number; time?: number; uncertainty?: number };
|
|
220
|
+
scheduleRelationship?: string;
|
|
221
|
+
}[];
|
|
222
|
+
timestamp?: number;
|
|
223
|
+
delay?: number;
|
|
224
|
+
};
|
|
225
|
+
vehicle?: unknown;
|
|
226
|
+
alert?: {
|
|
227
|
+
activePeriod: { start?: number; end?: number }[];
|
|
228
|
+
informedEntity: {
|
|
229
|
+
agencyId?: string;
|
|
230
|
+
routeId?: string;
|
|
231
|
+
routeType?: number;
|
|
232
|
+
stopId?: string;
|
|
233
|
+
trip?: { tripId?: string; routeId?: string };
|
|
234
|
+
}[];
|
|
235
|
+
cause?: string;
|
|
236
|
+
effect?: string;
|
|
237
|
+
url?: TranslatedString;
|
|
238
|
+
headerText?: TranslatedString;
|
|
239
|
+
descriptionText?: TranslatedString;
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface TranslatedString {
|
|
244
|
+
translation: { text: string; language?: string }[];
|
|
245
|
+
}
|