mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 05:01:17 +00:00
Compare commits
7 Commits
feat/aris-
...
0db6cae82b
| Author | SHA1 | Date | |
|---|---|---|---|
| 0db6cae82b | |||
| faad9e9736 | |||
| da2c1b9ee7 | |||
| c10c8a553a | |||
| fffcccc227 | |||
| 13300fb6a6 | |||
| 66ee44b470 |
@@ -8,14 +8,14 @@ bun install
|
|||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
### @aris/data-source-tfl
|
### @aris/source-tfl
|
||||||
|
|
||||||
TfL (Transport for London) data source for tube, overground, and Elizabeth line alerts.
|
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
|
||||||
|
|
||||||
#### Testing
|
#### Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd packages/aris-data-source-tfl
|
cd packages/aris-source-tfl
|
||||||
bun run test
|
bun run test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
8
apps/aris-backend/.env.example
Normal file
8
apps/aris-backend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# PostgreSQL connection string
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/aris
|
||||||
|
|
||||||
|
# BetterAuth secret (min 32 chars, generate with: openssl rand -base64 32)
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
|
# Base URL of the backend
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
"name": "@aris/backend",
|
"name": "@aris/backend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/server.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts",
|
"dev": "bun run --watch src/server.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/server.ts",
|
||||||
"test": "bun test src/"
|
"test": "bun test src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
"@aris/source-weatherkit": "workspace:*",
|
"@aris/source-weatherkit": "workspace:*",
|
||||||
"better-auth": "^1",
|
"better-auth": "^1",
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
"postgres": "^3"
|
"pg": "^8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/pg": "^8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/aris-backend/src/auth/http.ts
Normal file
7
apps/aris-backend/src/auth/http.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Hono } from "hono"
|
||||||
|
|
||||||
|
import { auth } from "./index.ts"
|
||||||
|
|
||||||
|
export function registerAuthHandlers(app: Hono): void {
|
||||||
|
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
|
||||||
|
}
|
||||||
10
apps/aris-backend/src/auth/index.ts
Normal file
10
apps/aris-backend/src/auth/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { betterAuth } from "better-auth"
|
||||||
|
|
||||||
|
import { pool } from "../db.ts"
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: pool,
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
54
apps/aris-backend/src/auth/session-middleware.ts
Normal file
54
apps/aris-backend/src/auth/session-middleware.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Context, Next } from "hono"
|
||||||
|
|
||||||
|
import { auth } from "./index.ts"
|
||||||
|
|
||||||
|
type SessionUser = typeof auth.$Infer.Session.user
|
||||||
|
type Session = typeof auth.$Infer.Session.session
|
||||||
|
|
||||||
|
export interface SessionVariables {
|
||||||
|
user: SessionUser | null
|
||||||
|
session: Session | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that attaches session and user to the context.
|
||||||
|
* Does not reject unauthenticated requests - use requireSession for that.
|
||||||
|
*/
|
||||||
|
export async function sessionMiddleware(c: Context, next: Next): Promise<void> {
|
||||||
|
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
c.set("user", session.user)
|
||||||
|
c.set("session", session.session)
|
||||||
|
} else {
|
||||||
|
c.set("user", null)
|
||||||
|
c.set("session", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that requires a valid session. Returns 401 if not authenticated.
|
||||||
|
*/
|
||||||
|
export async function requireSession(c: Context, next: Next): Promise<Response | void> {
|
||||||
|
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set("user", session.user)
|
||||||
|
c.set("session", session.session)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session from headers. Useful for WebSocket upgrade validation.
|
||||||
|
*/
|
||||||
|
export async function getSessionFromHeaders(
|
||||||
|
headers: Headers,
|
||||||
|
): Promise<{ user: SessionUser; session: Session } | null> {
|
||||||
|
const session = await auth.api.getSession({ headers })
|
||||||
|
return session
|
||||||
|
}
|
||||||
5
apps/aris-backend/src/db.ts
Normal file
5
apps/aris-backend/src/db.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Pool } from "pg"
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
})
|
||||||
8
apps/aris-backend/src/lib/error.ts
Normal file
8
apps/aris-backend/src/lib/error.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export class UserNotFoundError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
message?: string,
|
||||||
|
) {
|
||||||
|
super(message ? `${message}: user not found: ${userId}` : `User not found: ${userId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
apps/aris-backend/src/location/service.test.ts
Normal file
111
apps/aris-backend/src/location/service.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { UserNotFoundError } from "../lib/error.ts"
|
||||||
|
import { LocationService } from "./service.ts"
|
||||||
|
|
||||||
|
describe("LocationService", () => {
|
||||||
|
test("feedSourceForUser creates source on first call", () => {
|
||||||
|
const service = new LocationService()
|
||||||
|
const source = service.feedSourceForUser("user-1")
|
||||||
|
|
||||||
|
expect(source).toBeDefined()
|
||||||
|
expect(source.id).toBe("location")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feedSourceForUser returns same source for same user", () => {
|
||||||
|
const service = new LocationService()
|
||||||
|
const source1 = service.feedSourceForUser("user-1")
|
||||||
|
const source2 = service.feedSourceForUser("user-1")
|
||||||
|
|
||||||
|
expect(source1).toBe(source2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feedSourceForUser returns different sources for different users", () => {
|
||||||
|
const service = new LocationService()
|
||||||
|
const source1 = service.feedSourceForUser("user-1")
|
||||||
|
const source2 = service.feedSourceForUser("user-2")
|
||||||
|
|
||||||
|
expect(source1).not.toBe(source2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updateUserLocation updates the source", () => {
|
||||||
|
const service = new LocationService()
|
||||||
|
const source = service.feedSourceForUser("user-1")
|
||||||
|
const location = {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
service.updateUserLocation("user-1", location)
|
||||||
|
|
||||||
|
expect(source.lastLocation).toEqual(location)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updateUserLocation throws if source does not exist", () => {
|
||||||
|
const service = new LocationService()
|
||||||
|
const location = {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => service.updateUserLocation("user-1", location)).toThrow(UserNotFoundError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lastUserLocation returns null for unknown user", () => {
|
||||||
|
const service = new LocationService()
|
||||||
|
|
||||||
|
expect(service.lastUserLocation("unknown")).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lastUserLocation returns last location", () => {
|
||||||
|
const service = new LocationService()
|
||||||
|
service.feedSourceForUser("user-1")
|
||||||
|
const location1 = {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
const location2 = {
|
||||||
|
lat: 52.0,
|
||||||
|
lng: -0.2,
|
||||||
|
accuracy: 5,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
service.updateUserLocation("user-1", location1)
|
||||||
|
service.updateUserLocation("user-1", location2)
|
||||||
|
|
||||||
|
expect(service.lastUserLocation("user-1")).toEqual(location2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removeUser removes the source", () => {
|
||||||
|
const service = new LocationService()
|
||||||
|
service.feedSourceForUser("user-1")
|
||||||
|
const location = {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
service.updateUserLocation("user-1", location)
|
||||||
|
service.removeUser("user-1")
|
||||||
|
|
||||||
|
expect(service.lastUserLocation("user-1")).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removeUser allows new source to be created", () => {
|
||||||
|
const service = new LocationService()
|
||||||
|
const source1 = service.feedSourceForUser("user-1")
|
||||||
|
|
||||||
|
service.removeUser("user-1")
|
||||||
|
const source2 = service.feedSourceForUser("user-1")
|
||||||
|
|
||||||
|
expect(source1).not.toBe(source2)
|
||||||
|
})
|
||||||
|
})
|
||||||
55
apps/aris-backend/src/location/service.ts
Normal file
55
apps/aris-backend/src/location/service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { LocationSource, type Location } from "@aris/source-location"
|
||||||
|
|
||||||
|
import { UserNotFoundError } from "../lib/error.ts"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages LocationSource instances per user.
|
||||||
|
*/
|
||||||
|
export class LocationService {
|
||||||
|
private sources = new Map<string, LocationSource>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a LocationSource for a user.
|
||||||
|
* @param userId - The user's unique identifier
|
||||||
|
* @returns The user's LocationSource instance
|
||||||
|
*/
|
||||||
|
feedSourceForUser(userId: string): LocationSource {
|
||||||
|
let source = this.sources.get(userId)
|
||||||
|
if (!source) {
|
||||||
|
source = new LocationSource()
|
||||||
|
this.sources.set(userId, source)
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update location for a user.
|
||||||
|
* @param userId - The user's unique identifier
|
||||||
|
* @param location - The new location data
|
||||||
|
* @throws {UserNotFoundError} If no source exists for the user
|
||||||
|
*/
|
||||||
|
updateUserLocation(userId: string, location: Location): void {
|
||||||
|
const source = this.sources.get(userId)
|
||||||
|
if (!source) {
|
||||||
|
throw new UserNotFoundError(userId)
|
||||||
|
}
|
||||||
|
source.pushLocation(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last known location for a user.
|
||||||
|
* @param userId - The user's unique identifier
|
||||||
|
* @returns The last location, or null if none exists
|
||||||
|
*/
|
||||||
|
lastUserLocation(userId: string): Location | null {
|
||||||
|
return this.sources.get(userId)?.lastLocation ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a user's LocationSource.
|
||||||
|
* @param userId - The user's unique identifier
|
||||||
|
*/
|
||||||
|
removeUser(userId: string): void {
|
||||||
|
this.sources.delete(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import { registerAuthHandlers } from "./auth/http.ts"
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
|
registerAuthHandlers(app)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["bun-types"]
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
56
bun.lock
56
bun.lock
@@ -22,21 +22,16 @@
|
|||||||
"@aris/source-weatherkit": "workspace:*",
|
"@aris/source-weatherkit": "workspace:*",
|
||||||
"better-auth": "^1",
|
"better-auth": "^1",
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
"postgres": "^3",
|
"pg": "^8",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/pg": "^8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/aris-core": {
|
"packages/aris-core": {
|
||||||
"name": "@aris/core",
|
"name": "@aris/core",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
},
|
},
|
||||||
"packages/aris-data-source-tfl": {
|
|
||||||
"name": "@aris/data-source-tfl",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@aris/core": "workspace:*",
|
|
||||||
"arktype": "^2.1.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/aris-data-source-weatherkit": {
|
"packages/aris-data-source-weatherkit": {
|
||||||
"name": "@aris/data-source-weatherkit",
|
"name": "@aris/data-source-weatherkit",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -52,6 +47,15 @@
|
|||||||
"@aris/core": "workspace:*",
|
"@aris/core": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/aris-source-tfl": {
|
||||||
|
"name": "@aris/source-tfl",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
"@aris/source-location": "workspace:*",
|
||||||
|
"arktype": "^2.1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/aris-source-weatherkit": {
|
"packages/aris-source-weatherkit": {
|
||||||
"name": "@aris/source-weatherkit",
|
"name": "@aris/source-weatherkit",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -67,12 +71,12 @@
|
|||||||
|
|
||||||
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
||||||
|
|
||||||
"@aris/data-source-tfl": ["@aris/data-source-tfl@workspace:packages/aris-data-source-tfl"],
|
|
||||||
|
|
||||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||||
|
|
||||||
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
|
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
|
||||||
|
|
||||||
|
"@aris/source-tfl": ["@aris/source-tfl@workspace:packages/aris-source-tfl"],
|
||||||
|
|
||||||
"@aris/source-weatherkit": ["@aris/source-weatherkit@workspace:packages/aris-source-weatherkit"],
|
"@aris/source-weatherkit": ["@aris/source-weatherkit@workspace:packages/aris-source-weatherkit"],
|
||||||
|
|
||||||
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
|
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
|
||||||
@@ -129,6 +133,8 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||||
|
|
||||||
|
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
|
||||||
|
|
||||||
"arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="],
|
"arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="],
|
||||||
|
|
||||||
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
|
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
|
||||||
@@ -153,18 +159,44 @@
|
|||||||
|
|
||||||
"oxlint": ["oxlint@1.39.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.39.0", "@oxlint/darwin-x64": "1.39.0", "@oxlint/linux-arm64-gnu": "1.39.0", "@oxlint/linux-arm64-musl": "1.39.0", "@oxlint/linux-x64-gnu": "1.39.0", "@oxlint/linux-x64-musl": "1.39.0", "@oxlint/win32-arm64": "1.39.0", "@oxlint/win32-x64": "1.39.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.10.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-wSiLr0wjG+KTU6c1LpVoQk7JZ7l8HCKlAkVDVTJKWmCGazsNxexxnOXl7dsar92mQcRnzko5g077ggP3RINSjA=="],
|
"oxlint": ["oxlint@1.39.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.39.0", "@oxlint/darwin-x64": "1.39.0", "@oxlint/linux-arm64-gnu": "1.39.0", "@oxlint/linux-arm64-musl": "1.39.0", "@oxlint/linux-x64-gnu": "1.39.0", "@oxlint/linux-x64-musl": "1.39.0", "@oxlint/win32-arm64": "1.39.0", "@oxlint/win32-x64": "1.39.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.10.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-wSiLr0wjG+KTU6c1LpVoQk7JZ7l8HCKlAkVDVTJKWmCGazsNxexxnOXl7dsar92mQcRnzko5g077ggP3RINSjA=="],
|
||||||
|
|
||||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
|
||||||
|
|
||||||
|
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
|
||||||
|
|
||||||
|
"pg-connection-string": ["pg-connection-string@2.10.1", "", {}, "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw=="],
|
||||||
|
|
||||||
|
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||||
|
|
||||||
|
"pg-pool": ["pg-pool@3.11.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w=="],
|
||||||
|
|
||||||
|
"pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="],
|
||||||
|
|
||||||
|
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
|
||||||
|
|
||||||
|
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
|
||||||
|
|
||||||
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
|
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
||||||
|
|
||||||
|
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
|
||||||
|
|
||||||
|
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||||
|
|
||||||
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
"tinypool": ["tinypool@2.0.0", "", {}, "sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg=="],
|
"tinypool": ["tinypool@2.0.0", "", {}, "sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
export { TflDataSource } from "./data-source.ts"
|
|
||||||
export { TflApi, type ITflApi, type TflLineStatus } from "./tfl-api.ts"
|
|
||||||
export type {
|
|
||||||
TflAlertData,
|
|
||||||
TflAlertFeedItem,
|
|
||||||
TflAlertSeverity,
|
|
||||||
TflDataSourceConfig,
|
|
||||||
TflDataSourceOptions,
|
|
||||||
TflLineId,
|
|
||||||
StationLocation,
|
|
||||||
} from "./types.ts"
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import type { Context } from "@aris/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { ITflApi, TflLineStatus } from "./tfl-api.ts"
|
|
||||||
import type { StationLocation, TflLineId } from "./types.ts"
|
|
||||||
|
|
||||||
import fixtures from "../fixtures/tfl-responses.json"
|
|
||||||
import { TflDataSource } from "./data-source.ts"
|
|
||||||
|
|
||||||
// Mock API that returns fixture data
|
|
||||||
class FixtureTflApi implements ITflApi {
|
|
||||||
async fetchLineStatuses(_lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
|
||||||
const statuses: TflLineStatus[] = []
|
|
||||||
|
|
||||||
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
|
||||||
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
|
||||||
const severityCode = status.statusSeverity as number
|
|
||||||
const severity = this.mapSeverity(severityCode)
|
|
||||||
if (severity) {
|
|
||||||
statuses.push({
|
|
||||||
lineId: line.id as TflLineId,
|
|
||||||
lineName: line.name as string,
|
|
||||||
severity,
|
|
||||||
description: (status.reason as string) ?? (status.statusSeverityDescription as string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchStations(): Promise<StationLocation[]> {
|
|
||||||
const stationMap = new Map<string, StationLocation>()
|
|
||||||
|
|
||||||
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
|
||||||
for (const stop of stops as Record<string, unknown>[]) {
|
|
||||||
const id = stop.naptanId as string
|
|
||||||
const existing = stationMap.get(id)
|
|
||||||
if (existing) {
|
|
||||||
if (!existing.lines.includes(lineId as TflLineId)) {
|
|
||||||
existing.lines.push(lineId as TflLineId)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stationMap.set(id, {
|
|
||||||
id,
|
|
||||||
name: stop.commonName as string,
|
|
||||||
lat: stop.lat as number,
|
|
||||||
lng: stop.lon as number,
|
|
||||||
lines: [lineId as TflLineId],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(stationMap.values())
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapSeverity(code: number): "minor-delays" | "major-delays" | "closure" | null {
|
|
||||||
const map: Record<number, "minor-delays" | "major-delays" | "closure" | null> = {
|
|
||||||
1: "closure",
|
|
||||||
2: "closure",
|
|
||||||
3: "closure",
|
|
||||||
4: "closure",
|
|
||||||
5: "closure",
|
|
||||||
6: "major-delays",
|
|
||||||
7: "major-delays",
|
|
||||||
8: "major-delays",
|
|
||||||
9: "minor-delays",
|
|
||||||
10: null,
|
|
||||||
}
|
|
||||||
return map[code] ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createContext = (location?: { lat: number; lng: number }): Context => ({
|
|
||||||
time: new Date("2026-01-15T12:00:00Z"),
|
|
||||||
location: location ? { ...location, accuracy: 10 } : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("TfL Feed Items (using fixture data)", () => {
|
|
||||||
const api = new FixtureTflApi()
|
|
||||||
|
|
||||||
test("query returns feed items array", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
expect(Array.isArray(items)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feed items have correct base structure", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(typeof item.id).toBe("string")
|
|
||||||
expect(item.id).toMatch(/^tfl-alert-/)
|
|
||||||
expect(item.type).toBe("tfl-alert")
|
|
||||||
expect(typeof item.priority).toBe("number")
|
|
||||||
expect(item.timestamp).toBeInstanceOf(Date)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feed items have correct data structure", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(typeof item.data.line).toBe("string")
|
|
||||||
expect(typeof item.data.lineName).toBe("string")
|
|
||||||
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
|
|
||||||
expect(typeof item.data.description).toBe("string")
|
|
||||||
expect(
|
|
||||||
item.data.closestStationDistance === null ||
|
|
||||||
typeof item.data.closestStationDistance === "number",
|
|
||||||
).toBe(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feed item ids are unique", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
|
|
||||||
const ids = items.map((item) => item.id)
|
|
||||||
const uniqueIds = new Set(ids)
|
|
||||||
expect(uniqueIds.size).toBe(ids.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feed items are sorted by priority descending", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
|
|
||||||
for (let i = 1; i < items.length; i++) {
|
|
||||||
const prev = items[i - 1]!
|
|
||||||
const curr = items[i]!
|
|
||||||
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("priority values match severity levels", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
|
|
||||||
const severityPriority: Record<string, number> = {
|
|
||||||
closure: 100,
|
|
||||||
"major-delays": 80,
|
|
||||||
"minor-delays": 60,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("closestStationDistance is number when location provided", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(typeof item.data.closestStationDistance).toBe("number")
|
|
||||||
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("closestStationDistance is null when no location provided", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(item.data.closestStationDistance).toBeNull()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("TfL Fixture Data Shape", () => {
|
|
||||||
test("fixtures have expected structure", () => {
|
|
||||||
expect(typeof fixtures.fetchedAt).toBe("string")
|
|
||||||
expect(Array.isArray(fixtures.lineStatuses)).toBe(true)
|
|
||||||
expect(typeof fixtures.stopPoints).toBe("object")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("line statuses have required fields", () => {
|
|
||||||
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
|
||||||
expect(typeof line.id).toBe("string")
|
|
||||||
expect(typeof line.name).toBe("string")
|
|
||||||
expect(Array.isArray(line.lineStatuses)).toBe(true)
|
|
||||||
|
|
||||||
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
|
||||||
expect(typeof status.statusSeverity).toBe("number")
|
|
||||||
expect(typeof status.statusSeverityDescription).toBe("string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("stop points have required fields", () => {
|
|
||||||
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
|
||||||
expect(typeof lineId).toBe("string")
|
|
||||||
expect(Array.isArray(stops)).toBe(true)
|
|
||||||
|
|
||||||
for (const stop of stops as Record<string, unknown>[]) {
|
|
||||||
expect(typeof stop.naptanId).toBe("string")
|
|
||||||
expect(typeof stop.commonName).toBe("string")
|
|
||||||
expect(typeof stop.lat).toBe("number")
|
|
||||||
expect(typeof stop.lon).toBe("number")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
|
||||||
|
|
||||||
import type { TflLineId } from "./tfl-api.ts"
|
|
||||||
|
|
||||||
export type { TflLineId } from "./tfl-api.ts"
|
|
||||||
|
|
||||||
export type TflAlertSeverity = "minor-delays" | "major-delays" | "closure"
|
|
||||||
|
|
||||||
export interface TflAlertData extends Record<string, unknown> {
|
|
||||||
line: TflLineId
|
|
||||||
lineName: string
|
|
||||||
severity: TflAlertSeverity
|
|
||||||
description: string
|
|
||||||
closestStationDistance: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
|
|
||||||
|
|
||||||
export interface TflDataSourceConfig {
|
|
||||||
lines?: TflLineId[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TflDataSourceOptions {
|
|
||||||
apiKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StationLocation {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
lines: TflLineId[]
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@aris/data-source-tfl",
|
"name": "@aris/source-tfl",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aris/core": "workspace:*",
|
"@aris/core": "workspace:*",
|
||||||
|
"@aris/source-location": "workspace:*",
|
||||||
"arktype": "^2.1.0"
|
"arktype": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
11
packages/aris-source-tfl/src/index.ts
Normal file
11
packages/aris-source-tfl/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { TflSource } from "./tfl-source.ts"
|
||||||
|
export { TflApi } from "./tfl-api.ts"
|
||||||
|
export type { TflLineId } from "./tfl-api.ts"
|
||||||
|
export type {
|
||||||
|
StationLocation,
|
||||||
|
TflAlertData,
|
||||||
|
TflAlertFeedItem,
|
||||||
|
TflAlertSeverity,
|
||||||
|
TflLineStatus,
|
||||||
|
TflSourceOptions,
|
||||||
|
} from "./types.ts"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
|
|
||||||
import type { StationLocation, TflAlertSeverity } from "./types.ts"
|
import type { StationLocation, TflAlertSeverity, TflLineStatus } from "./types.ts"
|
||||||
|
|
||||||
const TFL_API_BASE = "https://api.tfl.gov.uk"
|
const TFL_API_BASE = "https://api.tfl.gov.uk"
|
||||||
|
|
||||||
@@ -50,19 +50,7 @@ const SEVERITY_MAP: Record<number, TflAlertSeverity | null> = {
|
|||||||
20: null, // Service Closed
|
20: null, // Service Closed
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TflLineStatus {
|
export class TflApi {
|
||||||
lineId: TflLineId
|
|
||||||
lineName: string
|
|
||||||
severity: TflAlertSeverity
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ITflApi {
|
|
||||||
fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]>
|
|
||||||
fetchStations(): Promise<StationLocation[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TflApi implements ITflApi {
|
|
||||||
private apiKey: string
|
private apiKey: string
|
||||||
private stationsCache: StationLocation[] | null = null
|
private stationsCache: StationLocation[] | null = null
|
||||||
|
|
||||||
243
packages/aris-source-tfl/src/tfl-source.test.ts
Normal file
243
packages/aris-source-tfl/src/tfl-source.test.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
|
import { LocationKey, type Location } from "@aris/source-location"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ITflApi,
|
||||||
|
StationLocation,
|
||||||
|
TflAlertSeverity,
|
||||||
|
TflLineId,
|
||||||
|
TflLineStatus,
|
||||||
|
} from "./types.ts"
|
||||||
|
|
||||||
|
import fixtures from "../fixtures/tfl-responses.json"
|
||||||
|
import { TflSource } from "./tfl-source.ts"
|
||||||
|
|
||||||
|
// Mock API that returns fixture data
|
||||||
|
class FixtureTflApi implements ITflApi {
|
||||||
|
async fetchLineStatuses(_lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||||
|
const statuses: TflLineStatus[] = []
|
||||||
|
|
||||||
|
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
||||||
|
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
||||||
|
const severityCode = status.statusSeverity as number
|
||||||
|
const severity = this.mapSeverity(severityCode)
|
||||||
|
if (severity) {
|
||||||
|
statuses.push({
|
||||||
|
lineId: line.id as TflLineId,
|
||||||
|
lineName: line.name as string,
|
||||||
|
severity,
|
||||||
|
description: (status.reason as string) ?? (status.statusSeverityDescription as string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchStations(): Promise<StationLocation[]> {
|
||||||
|
const stationMap = new Map<string, StationLocation>()
|
||||||
|
|
||||||
|
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
||||||
|
for (const stop of stops as Record<string, unknown>[]) {
|
||||||
|
const id = stop.naptanId as string
|
||||||
|
const existing = stationMap.get(id)
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.lines.includes(lineId as TflLineId)) {
|
||||||
|
existing.lines.push(lineId as TflLineId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stationMap.set(id, {
|
||||||
|
id,
|
||||||
|
name: stop.commonName as string,
|
||||||
|
lat: stop.lat as number,
|
||||||
|
lng: stop.lon as number,
|
||||||
|
lines: [lineId as TflLineId],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(stationMap.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapSeverity(code: number): TflAlertSeverity | null {
|
||||||
|
const map: Record<number, TflAlertSeverity | null> = {
|
||||||
|
1: "closure",
|
||||||
|
2: "closure",
|
||||||
|
3: "closure",
|
||||||
|
4: "closure",
|
||||||
|
5: "closure",
|
||||||
|
6: "major-delays",
|
||||||
|
7: "major-delays",
|
||||||
|
8: "major-delays",
|
||||||
|
9: "minor-delays",
|
||||||
|
10: null,
|
||||||
|
}
|
||||||
|
return map[code] ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext(location?: Location): Context {
|
||||||
|
const ctx: Context = { time: new Date("2026-01-15T12:00:00Z") }
|
||||||
|
if (location) {
|
||||||
|
ctx[LocationKey] = location
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TflSource", () => {
|
||||||
|
const api = new FixtureTflApi()
|
||||||
|
|
||||||
|
describe("interface", () => {
|
||||||
|
test("has correct id", () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
expect(source.id).toBe("tfl")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("depends on location", () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
expect(source.dependencies).toEqual(["location"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("implements fetchItems", () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
expect(source.fetchItems).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws if neither client nor apiKey provided", () => {
|
||||||
|
expect(() => new TflSource({})).toThrow("Either client or apiKey must be provided")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("fetchItems", () => {
|
||||||
|
test("returns feed items array", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
expect(Array.isArray(items)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed items have correct base structure", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||||
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(typeof item.id).toBe("string")
|
||||||
|
expect(item.id).toMatch(/^tfl-alert-/)
|
||||||
|
expect(item.type).toBe("tfl-alert")
|
||||||
|
expect(typeof item.priority).toBe("number")
|
||||||
|
expect(item.timestamp).toBeInstanceOf(Date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed items have correct data structure", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||||
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(typeof item.data.line).toBe("string")
|
||||||
|
expect(typeof item.data.lineName).toBe("string")
|
||||||
|
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
|
||||||
|
expect(typeof item.data.description).toBe("string")
|
||||||
|
expect(
|
||||||
|
item.data.closestStationDistance === null ||
|
||||||
|
typeof item.data.closestStationDistance === "number",
|
||||||
|
).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed item ids are unique", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
|
const ids = items.map((item) => item.id)
|
||||||
|
const uniqueIds = new Set(ids)
|
||||||
|
expect(uniqueIds.size).toBe(ids.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed items are sorted by priority descending", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const prev = items[i - 1]!
|
||||||
|
const curr = items[i]!
|
||||||
|
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("priority values match severity levels", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
|
const severityPriority: Record<string, number> = {
|
||||||
|
closure: 1.0,
|
||||||
|
"major-delays": 0.8,
|
||||||
|
"minor-delays": 0.6,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("closestStationDistance is number when location provided", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||||
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(typeof item.data.closestStationDistance).toBe("number")
|
||||||
|
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("closestStationDistance is null when no location provided", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.data.closestStationDistance).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("TfL Fixture Data Shape", () => {
|
||||||
|
test("fixtures have expected structure", () => {
|
||||||
|
expect(typeof fixtures.fetchedAt).toBe("string")
|
||||||
|
expect(Array.isArray(fixtures.lineStatuses)).toBe(true)
|
||||||
|
expect(typeof fixtures.stopPoints).toBe("object")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("line statuses have required fields", () => {
|
||||||
|
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
||||||
|
expect(typeof line.id).toBe("string")
|
||||||
|
expect(typeof line.name).toBe("string")
|
||||||
|
expect(Array.isArray(line.lineStatuses)).toBe(true)
|
||||||
|
|
||||||
|
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
||||||
|
expect(typeof status.statusSeverity).toBe("number")
|
||||||
|
expect(typeof status.statusSeverityDescription).toBe("string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stop points have required fields", () => {
|
||||||
|
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
||||||
|
expect(typeof lineId).toBe("string")
|
||||||
|
expect(Array.isArray(stops)).toBe(true)
|
||||||
|
|
||||||
|
for (const stop of stops as Record<string, unknown>[]) {
|
||||||
|
expect(typeof stop.naptanId).toBe("string")
|
||||||
|
expect(typeof stop.commonName).toBe("string")
|
||||||
|
expect(typeof stop.lat).toBe("number")
|
||||||
|
expect(typeof stop.lon).toBe("number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,21 +1,104 @@
|
|||||||
import type { Context, DataSource } from "@aris/core"
|
import type { Context, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
|
import { contextValue } from "@aris/core"
|
||||||
|
import { LocationKey } from "@aris/source-location"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ITflApi,
|
||||||
StationLocation,
|
StationLocation,
|
||||||
TflAlertData,
|
TflAlertData,
|
||||||
TflAlertFeedItem,
|
TflAlertFeedItem,
|
||||||
TflAlertSeverity,
|
TflAlertSeverity,
|
||||||
TflDataSourceConfig,
|
|
||||||
TflDataSourceOptions,
|
|
||||||
TflLineId,
|
TflLineId,
|
||||||
|
TflSourceOptions,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { TflApi, type ITflApi } from "./tfl-api.ts"
|
import { TflApi } from "./tfl-api.ts"
|
||||||
|
|
||||||
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
||||||
closure: 100,
|
closure: 1.0,
|
||||||
"major-delays": 80,
|
"major-delays": 0.8,
|
||||||
"minor-delays": 60,
|
"minor-delays": 0.6,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FeedSource that provides TfL (Transport for London) service alerts.
|
||||||
|
*
|
||||||
|
* Depends on location source for proximity-based sorting. Produces feed items
|
||||||
|
* for tube, overground, and Elizabeth line disruptions.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const tflSource = new TflSource({
|
||||||
|
* apiKey: process.env.TFL_API_KEY!,
|
||||||
|
* lines: ["northern", "victoria", "jubilee"],
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* const engine = new FeedEngine()
|
||||||
|
* .register(locationSource)
|
||||||
|
* .register(tflSource)
|
||||||
|
*
|
||||||
|
* const { items } = await engine.refresh()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||||
|
readonly id = "tfl"
|
||||||
|
readonly dependencies = ["location"]
|
||||||
|
|
||||||
|
private readonly client: ITflApi
|
||||||
|
private readonly lines?: TflLineId[]
|
||||||
|
|
||||||
|
constructor(options: TflSourceOptions) {
|
||||||
|
if (!options.client && !options.apiKey) {
|
||||||
|
throw new Error("Either client or apiKey must be provided")
|
||||||
|
}
|
||||||
|
this.client = options.client ?? new TflApi(options.apiKey!)
|
||||||
|
this.lines = options.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
|
||||||
|
const [statuses, stations] = await Promise.all([
|
||||||
|
this.client.fetchLineStatuses(this.lines),
|
||||||
|
this.client.fetchStations(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const location = contextValue(context, LocationKey)
|
||||||
|
|
||||||
|
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||||
|
const closestStationDistance = location
|
||||||
|
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const data: TflAlertData = {
|
||||||
|
line: status.lineId,
|
||||||
|
lineName: status.lineName,
|
||||||
|
severity: status.severity,
|
||||||
|
description: status.description,
|
||||||
|
closestStationDistance,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||||
|
type: "tfl-alert",
|
||||||
|
priority: SEVERITY_PRIORITY[status.severity],
|
||||||
|
timestamp: context.time,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by severity (desc), then by proximity (asc) if location available
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (b.priority !== a.priority) {
|
||||||
|
return b.priority - a.priority
|
||||||
|
}
|
||||||
|
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
||||||
|
return a.data.closestStationDistance - b.data.closestStationDistance
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
@@ -51,65 +134,3 @@ function findClosestStationDistance(
|
|||||||
|
|
||||||
return minDistance
|
return minDistance
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TflDataSource implements DataSource<TflAlertFeedItem, TflDataSourceConfig> {
|
|
||||||
readonly type = "tfl-alert"
|
|
||||||
private api: ITflApi
|
|
||||||
|
|
||||||
constructor(options: TflDataSourceOptions)
|
|
||||||
constructor(api: ITflApi)
|
|
||||||
constructor(optionsOrApi: TflDataSourceOptions | ITflApi) {
|
|
||||||
if ("fetchLineStatuses" in optionsOrApi) {
|
|
||||||
this.api = optionsOrApi
|
|
||||||
} else {
|
|
||||||
this.api = new TflApi(optionsOrApi.apiKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async query(context: Context, config: TflDataSourceConfig): Promise<TflAlertFeedItem[]> {
|
|
||||||
const [statuses, stations] = await Promise.all([
|
|
||||||
this.api.fetchLineStatuses(config.lines),
|
|
||||||
this.api.fetchStations(),
|
|
||||||
])
|
|
||||||
|
|
||||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
|
||||||
const closestStationDistance = context.location
|
|
||||||
? findClosestStationDistance(
|
|
||||||
status.lineId,
|
|
||||||
stations,
|
|
||||||
context.location.lat,
|
|
||||||
context.location.lng,
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const data: TflAlertData = {
|
|
||||||
line: status.lineId,
|
|
||||||
lineName: status.lineName,
|
|
||||||
severity: status.severity,
|
|
||||||
description: status.description,
|
|
||||||
closestStationDistance,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
|
||||||
type: this.type,
|
|
||||||
priority: SEVERITY_PRIORITY[status.severity],
|
|
||||||
timestamp: context.time,
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort by severity (desc), then by proximity (asc) if location available
|
|
||||||
items.sort((a, b) => {
|
|
||||||
if (b.priority !== a.priority) {
|
|
||||||
return b.priority - a.priority
|
|
||||||
}
|
|
||||||
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
|
||||||
return a.data.closestStationDistance - b.data.closestStationDistance
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
50
packages/aris-source-tfl/src/types.ts
Normal file
50
packages/aris-source-tfl/src/types.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { FeedItem } from "@aris/core"
|
||||||
|
|
||||||
|
import type { TflLineId } from "./tfl-api.ts"
|
||||||
|
|
||||||
|
export type { TflLineId } from "./tfl-api.ts"
|
||||||
|
|
||||||
|
export const TflAlertSeverity = {
|
||||||
|
MinorDelays: "minor-delays",
|
||||||
|
MajorDelays: "major-delays",
|
||||||
|
Closure: "closure",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TflAlertSeverity = (typeof TflAlertSeverity)[keyof typeof TflAlertSeverity]
|
||||||
|
|
||||||
|
export interface TflAlertData extends Record<string, unknown> {
|
||||||
|
line: TflLineId
|
||||||
|
lineName: string
|
||||||
|
severity: TflAlertSeverity
|
||||||
|
description: string
|
||||||
|
closestStationDistance: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
|
||||||
|
|
||||||
|
export interface TflSourceOptions {
|
||||||
|
apiKey?: string
|
||||||
|
client?: ITflApi
|
||||||
|
/** Lines to monitor. Defaults to all lines. */
|
||||||
|
lines?: TflLineId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StationLocation {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
lines: TflLineId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITflApi {
|
||||||
|
fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]>
|
||||||
|
fetchStations(): Promise<StationLocation[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TflLineStatus {
|
||||||
|
lineId: TflLineId
|
||||||
|
lineName: string
|
||||||
|
severity: TflAlertSeverity
|
||||||
|
description: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user