Build TFI Live G2 deployable app

This commit is contained in:
Matiss
2026-06-01 16:48:03 +01:00
commit 60be45bb85
16 changed files with 5269 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
import { createReadStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createInterface } from 'node:readline'
export async function buildStopIndex(gtfsDir, outputPath = 'public/stops.json') {
const stops = readCsv(join(gtfsDir, 'stops.txt'))
const routes = readCsv(join(gtfsDir, 'routes.txt'))
const trips = readCsv(join(gtfsDir, 'trips.txt'))
const routeById = new Map(routes.map((route) => [route.route_id, route]))
const tripRouteById = new Map(trips.map((trip) => [trip.trip_id, trip.route_id]))
const routesByStop = new Map()
const modesByStop = new Map()
await eachCsvRow(join(gtfsDir, 'stop_times.txt'), (row) => {
const stopId = row.stop_id
const routeId = tripRouteById.get(row.trip_id)
if (!stopId || !routeId) {
return
}
const route = routeById.get(routeId)
const routeName = route?.route_short_name || route?.route_long_name || routeId
pushUnique(routesByStop, stopId, routeName)
pushUnique(modesByStop, stopId, modeFromRouteType(route?.route_type))
})
const index = stops
.map((stop) => ({
id: stop.stop_id,
code: stop.stop_code || stop.stop_id,
name: stop.stop_name,
lat: Number(stop.stop_lat),
lon: Number(stop.stop_lon),
modes: modesByStop.get(stop.stop_id) || ['unknown'],
routes: routesByStop.get(stop.stop_id) || [],
}))
.filter((stop) => stop.id && stop.name && Number.isFinite(stop.lat) && Number.isFinite(stop.lon))
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, `${JSON.stringify({ stops: index })}\n`)
return index.length
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const [gtfsDir, outputPath = 'public/stops.json'] = process.argv.slice(2)
if (!gtfsDir) {
console.error('Usage: node scripts/build-stop-index.mjs <extracted-gtfs-dir> [output.json]')
process.exit(1)
}
const count = await buildStopIndex(gtfsDir, outputPath)
console.log(`Wrote ${count} stops to ${outputPath}`)
}
function readCsv(path) {
const [headerLine, ...lines] = readFileSync(path, 'utf8').trim().split(/\r?\n/)
const headers = parseCsvLine(headerLine)
return lines.map((line) => rowFromValues(headers, parseCsvLine(line)))
}
async function eachCsvRow(path, callback) {
const reader = createInterface({
input: createReadStream(path),
crlfDelay: Infinity,
})
let headers = []
for await (const line of reader) {
if (!headers.length) {
headers = parseCsvLine(line)
continue
}
callback(rowFromValues(headers, parseCsvLine(line)))
}
}
function rowFromValues(headers, values) {
return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']))
}
function parseCsvLine(line) {
const values = []
let value = ''
let quoted = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
const next = line[index + 1]
if (char === '"' && quoted && next === '"') {
value += '"'
index += 1
} else if (char === '"') {
quoted = !quoted
} else if (char === ',' && !quoted) {
values.push(value)
value = ''
} else {
value += char
}
}
values.push(value)
return values
}
function pushUnique(map, key, value) {
if (!value) {
return
}
const values = map.get(key) || []
if (!values.includes(value)) {
values.push(value)
map.set(key, values)
}
}
function modeFromRouteType(routeType) {
switch (String(routeType)) {
case '0':
return 'tram'
case '2':
return 'rail'
case '3':
return 'bus'
default:
return 'unknown'
}
}

135
scripts/smoke-test.mjs Normal file
View File

@@ -0,0 +1,135 @@
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildStopIndex } from './build-stop-index.mjs'
import worker from '../worker/nta-proxy.js'
const tempDir = mkdtempSync(join(tmpdir(), 'tfi-live-g2-'))
try {
await testStopIndexBuilder()
await testWorkerDepartures()
testToolkitMigration()
testStopsJson()
testNoLocalSecretBundled()
console.log('Smoke tests passed')
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
async function testStopIndexBuilder() {
const gtfsDir = join(tempDir, 'gtfs')
mkdirSync(gtfsDir, { recursive: true })
writeFileSync(join(gtfsDir, 'stops.txt'), 'stop_id,stop_code,stop_name,stop_lat,stop_lon\nS1,100,Main Street,53.1,-6.1\n')
writeFileSync(join(gtfsDir, 'routes.txt'), 'route_id,route_short_name,route_long_name,route_type\nR1,46A,Route 46A,3\n')
writeFileSync(join(gtfsDir, 'trips.txt'), 'route_id,service_id,trip_id\nR1,W,T1\n')
writeFileSync(join(gtfsDir, 'stop_times.txt'), 'trip_id,arrival_time,departure_time,stop_id,stop_sequence\nT1,10:00:00,10:00:00,S1,1\n')
const outputPath = join(tempDir, 'stops.json')
await buildStopIndex(gtfsDir, outputPath)
const payload = JSON.parse(readFileSync(outputPath, 'utf8'))
assert(payload.stops?.[0]?.routes?.[0] === '46A', 'stop index should include route short name')
assert(payload.stops?.[0]?.modes?.[0] === 'bus', 'stop index should infer bus mode')
}
async function testWorkerDepartures() {
const nowSeconds = Math.floor(Date.now() / 1000)
const feed = {
entity: [
{
id: 'fresh',
tripUpdate: {
trip: { routeId: '46A', tripHeadsign: 'Dun Laoghaire' },
stopTimeUpdate: [{ stopId: 'S1', departure: { time: nowSeconds + 300 } }],
},
},
{
id: 'stale',
tripUpdate: {
trip: { routeId: '46A', tripHeadsign: 'Past' },
stopTimeUpdate: [{ stopId: 'S1', departure: { time: nowSeconds - 600 } }],
},
},
],
}
const cacheStore = new Map()
globalThis.caches = {
default: {
match: async (request) => cacheStore.get(request.url),
put: async (request, response) => {
cacheStore.set(request.url, response)
},
},
}
globalThis.fetch = async () => Response.json(feed)
const response = await worker.fetch(
new Request('https://proxy.example/departures?stopId=S1'),
{ NTA_API_KEY: 'test' },
{ waitUntil: () => undefined },
)
assert(response.ok, 'worker departures response should be ok')
const payload = await response.json()
assert(payload.departures.length === 1, 'worker should filter stale departures')
assert(payload.departures[0].route === '46A', 'worker should preserve route id')
}
function testNoLocalSecretBundled() {
const key = process.env.NTA_API_KEY
if (!key) {
return
}
const matches = findFilesContaining(key, ['dist', 'src', 'worker', 'scripts', 'README.md', 'app.json'])
assert(matches.length === 0, `local NTA_API_KEY must not be bundled or documented verbatim: ${matches.join(', ')}`)
}
function testStopsJson() {
const payload = JSON.parse(readFileSync('public/stops.json', 'utf8'))
assert(payload.stops?.length > 10_000, 'public/stops.json should contain the generated TFI stop index')
assert(payload.stops.some((stop) => stop.name && stop.lat && stop.lon), 'stops should include names and coordinates')
}
function testToolkitMigration() {
const source = readFileSync('src/main.ts', 'utf8')
assert(source.includes("from 'even-toolkit/bridge'"), 'app should use Even toolkit bridge')
assert(source.includes("from 'even-toolkit/glass-display-builders'"), 'glasses lists should use toolkit display builders')
assert(source.includes("from 'even-toolkit/web/button'"), 'phone UI should use Even toolkit Button')
assert(source.includes("from 'even-toolkit/web/list-item'"), 'phone lists should use Even toolkit ListItem')
assert(source.includes("from 'even-toolkit/web/stat-grid'"), 'phone status grid should use Even toolkit StatGrid')
assert(source.includes("from 'react-dom/client'"), 'phone UI should be rendered through React toolkit components')
assert(!source.includes('createStartUpPageContainer'), 'app should not render glasses UI through raw startup containers')
assert(!source.includes('new TextContainerUpgrade'), 'app should not update glasses UI through raw text upgrades')
assert(!source.includes('document.createElement'), 'phone UI should not use hand-built DOM rendering')
assert(!source.includes('innerHTML'), 'phone UI should not render raw HTML strings')
}
function findFilesContaining(needle, paths) {
const matches = []
for (const path of paths) {
collectMatches(path, needle, matches)
}
return matches
}
function collectMatches(path, needle, matches) {
const stats = statSync(path)
if (stats.isDirectory()) {
for (const entry of readdirSync(path)) {
collectMatches(join(path, entry), needle, matches)
}
return
}
const content = readFileSync(path, 'utf8')
if (content.includes(needle)) {
matches.push(path)
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}