feat: normalize live PRIM departures (#12)Unverified
5a0b437 parent: acf5bba added
.github/workflows/test.yml +27 -0 | new file mode 100644 | ||
| @@ -0,0 +1,27 @@ | ||
| 1 | +name: Tests | |
| 2 | + | |
| 3 | +on: | |
| 4 | + pull_request: | |
| 5 | + branches: [develop] | |
| 6 | + | |
| 7 | +permissions: | |
| 8 | + contents: read | |
| 9 | + | |
| 10 | +concurrency: | |
| 11 | + group: ${{ github.workflow }}-${{ github.ref }} | |
| 12 | + cancel-in-progress: true | |
| 13 | + | |
| 14 | +jobs: | |
| 15 | + test: | |
| 16 | + runs-on: ubuntu-latest | |
| 17 | + steps: | |
| 18 | + - uses: actions/checkout@v4 | |
| 19 | + | |
| 20 | + - uses: actions/setup-node@v4 | |
| 21 | + with: | |
| 22 | + node-version-file: .node-version | |
| 23 | + cache: npm | |
| 24 | + | |
| 25 | + - run: npm ci | |
| 26 | + | |
| 27 | + - run: npm test | |
| new file mode 100644 | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | +name: Tests | ||
| 2 | + | ||
| 3 | +on: | ||
| 4 | + pull_request: | ||
| 5 | + branches: [develop] | ||
| 6 | + | ||
| 7 | +permissions: | ||
| 8 | + contents: read | ||
| 9 | + | ||
| 10 | +concurrency: | ||
| 11 | + group: ${{ github.workflow }}-${{ github.ref }} | ||
| 12 | + cancel-in-progress: true | ||
| 13 | + | ||
| 14 | +jobs: | ||
| 15 | + test: | ||
| 16 | + runs-on: ubuntu-latest | ||
| 17 | + steps: | ||
| 18 | + - uses: actions/checkout@v4 | ||
| 19 | + | ||
| 20 | + - uses: actions/setup-node@v4 | ||
| 21 | + with: | ||
| 22 | + node-version-file: .node-version | ||
| 23 | + cache: npm | ||
| 24 | + | ||
| 25 | + - run: npm ci | ||
| 26 | + | ||
| 27 | + - run: npm test | ||
modified
.gitignore +3 -0 | @@ -1,4 +1,7 @@ | ||
| 1 | 1 | node_modules/ |
| 2 | +.env | |
| 3 | +.env.* | |
| 4 | +!.env.example | |
| 2 | 5 | var/ |
| 3 | 6 | packages/watch/build/ |
| 4 | 7 | packages/watch/src/pkjs/ |
| @@ -1,4 +1,7 @@ | |||
| 1 | node_modules/ | 1 | node_modules/ |
| 2 | +.env | ||
| 3 | +.env.* | ||
| 4 | +!.env.example | ||
| 2 | var/ | 5 | var/ |
| 3 | packages/watch/build/ | 6 | packages/watch/build/ |
| 4 | packages/watch/src/pkjs/ | 7 | packages/watch/src/pkjs/ |
added
fixtures/departures/prim/bus.json +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:45Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:1001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:1001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:32:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:31:00Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:40Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:1001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:1001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:38:00Z", | |
| 32 | + "AimedDepartureTime": "2026-01-15T08:37:30Z", | |
| 33 | + "DepartureStatus": "ontime" | |
| 34 | + } | |
| 35 | + } | |
| 36 | + } | |
| 37 | + ] | |
| 38 | + } | |
| 39 | + ] | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:45Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:1001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:1001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:32:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:31:00Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:40Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:1001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:1001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:38:00Z", | ||
| 32 | + "AimedDepartureTime": "2026-01-15T08:37:30Z", | ||
| 33 | + "DepartureStatus": "ontime" | ||
| 34 | + } | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + ] | ||
| 38 | + } | ||
| 39 | + ] | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | +} | ||
added
fixtures/departures/prim/cancelled.json +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:57Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:46Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C3001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:3001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:32:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:31:30Z", | |
| 19 | + "DepartureStatus": "cancelled" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:45Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C3001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:3001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:35:00Z", | |
| 32 | + "AimedDepartureTime": "2026-01-15T08:34:30Z", | |
| 33 | + "DepartureStatus": "ontime" | |
| 34 | + } | |
| 35 | + } | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | |
| 39 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | |
| 40 | + "MonitoredVehicleJourney": { | |
| 41 | + "LineRef": { "value": "IDFM:C3001" }, | |
| 42 | + "DirectionRef": { "value": "IDFM:3001D" }, | |
| 43 | + "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 44 | + "MonitoredCall": { | |
| 45 | + "ExpectedDepartureTime": "2026-01-15T08:40:00Z", | |
| 46 | + "AimedDepartureTime": "2026-01-15T08:39:30Z", | |
| 47 | + "DepartureStatus": "ontime" | |
| 48 | + } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + ] | |
| 52 | + } | |
| 53 | + ] | |
| 54 | + } | |
| 55 | + } | |
| 56 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:57Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:46Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C3001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:3001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:3001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:32:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:31:30Z", | ||
| 19 | + "DepartureStatus": "cancelled" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:45Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C3001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:3001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:3001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:35:00Z", | ||
| 32 | + "AimedDepartureTime": "2026-01-15T08:34:30Z", | ||
| 33 | + "DepartureStatus": "ontime" | ||
| 34 | + } | ||
| 35 | + } | ||
| 36 | + }, | ||
| 37 | + { | ||
| 38 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | ||
| 39 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | ||
| 40 | + "MonitoredVehicleJourney": { | ||
| 41 | + "LineRef": { "value": "IDFM:C3001" }, | ||
| 42 | + "DirectionRef": { "value": "IDFM:3001D" }, | ||
| 43 | + "DestinationRef": { "value": "IDFM:3001DST" }, | ||
| 44 | + "MonitoredCall": { | ||
| 45 | + "ExpectedDepartureTime": "2026-01-15T08:40:00Z", | ||
| 46 | + "AimedDepartureTime": "2026-01-15T08:39:30Z", | ||
| 47 | + "DepartureStatus": "ontime" | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + } | ||
| 51 | + ] | ||
| 52 | + } | ||
| 53 | + ] | ||
| 54 | + } | ||
| 55 | + } | ||
| 56 | +} | ||
added
fixtures/departures/prim/delayed.json +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:52Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:37:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | |
| 19 | + "DepartureStatus": "delayed" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + } | |
| 23 | + ] | |
| 24 | + } | |
| 25 | + ] | |
| 26 | + } | |
| 27 | + } | |
| 28 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:52Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:37:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 19 | + "DepartureStatus": "delayed" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + } | ||
| 23 | + ] | ||
| 24 | + } | ||
| 25 | + ] | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | +} | ||
added
fixtures/departures/prim/malformed.json +26 -0 | new file mode 100644 | ||
| @@ -0,0 +1,26 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:52Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "DepartureStatus": "ontime" | |
| 18 | + } | |
| 19 | + } | |
| 20 | + } | |
| 21 | + ] | |
| 22 | + } | |
| 23 | + ] | |
| 24 | + } | |
| 25 | + } | |
| 26 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:52Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "DepartureStatus": "ontime" | ||
| 18 | + } | ||
| 19 | + } | ||
| 20 | + } | ||
| 21 | + ] | ||
| 22 | + } | ||
| 23 | + ] | ||
| 24 | + } | ||
| 25 | + } | ||
| 26 | +} | ||
added
fixtures/departures/prim/metro.json +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:50Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:34:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + } | |
| 23 | + ] | |
| 24 | + } | |
| 25 | + ] | |
| 26 | + } | |
| 27 | + } | |
| 28 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:50Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:34:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + } | ||
| 23 | + ] | ||
| 24 | + } | ||
| 25 | + ] | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | +} | ||
added
fixtures/departures/prim/mixed.json +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:57Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:33:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:32:30Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:43Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "AimedDepartureTime": "2026-01-15T08:41:00Z" | |
| 32 | + } | |
| 33 | + } | |
| 34 | + }, | |
| 35 | + { | |
| 36 | + "RecordedAtTime": "2026-01-15T08:29:42Z", | |
| 37 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 38 | + "MonitoredVehicleJourney": { | |
| 39 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 40 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 41 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 42 | + "MonitoredCall": { | |
| 43 | + "ExpectedDepartureTime": "2026-01-15T08:49:00Z", | |
| 44 | + "AimedDepartureTime": "2026-01-15T08:48:00Z", | |
| 45 | + "DepartureStatus": "ontime" | |
| 46 | + } | |
| 47 | + } | |
| 48 | + } | |
| 49 | + ] | |
| 50 | + } | |
| 51 | + ] | |
| 52 | + } | |
| 53 | + } | |
| 54 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:57Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:32:30Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:43Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "AimedDepartureTime": "2026-01-15T08:41:00Z" | ||
| 32 | + } | ||
| 33 | + } | ||
| 34 | + }, | ||
| 35 | + { | ||
| 36 | + "RecordedAtTime": "2026-01-15T08:29:42Z", | ||
| 37 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 38 | + "MonitoredVehicleJourney": { | ||
| 39 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 40 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 41 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 42 | + "MonitoredCall": { | ||
| 43 | + "ExpectedDepartureTime": "2026-01-15T08:49:00Z", | ||
| 44 | + "AimedDepartureTime": "2026-01-15T08:48:00Z", | ||
| 45 | + "DepartureStatus": "ontime" | ||
| 46 | + } | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + ] | ||
| 50 | + } | ||
| 51 | + ] | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | +} | ||
added
fixtures/departures/prim/partial.json +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "StopMonitoringDelivery": [ | |
| 5 | + { | |
| 6 | + "MonitoredStopVisit": [ | |
| 7 | + { | |
| 8 | + "RecordedAtTime": "2026-01-15T08:29:36Z", | |
| 9 | + "MonitoringRef": { "value": "IDFM:SP:1099" }, | |
| 10 | + "MonitoredVehicleJourney": { | |
| 11 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 12 | + "DirectionRef": { "value": "IDFM:1099D" }, | |
| 13 | + "DestinationRef": { "value": "IDFM:1099DST" }, | |
| 14 | + "MonitoredCall": { | |
| 15 | + "ExpectedDepartureTime": "2026-01-15T08:31:00Z", | |
| 16 | + "AimedDepartureTime": "2026-01-15T08:31:00Z", | |
| 17 | + "DepartureStatus": "ontime" | |
| 18 | + } | |
| 19 | + } | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "RecordedAtTime": "2026-01-15T08:29:35Z", | |
| 23 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 24 | + "MonitoredVehicleJourney": { | |
| 25 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 26 | + "DirectionRef": { "value": "IDFM:1001D" }, | |
| 27 | + "DestinationRef": { "value": "IDFM:1001DST" }, | |
| 28 | + "MonitoredCall": { | |
| 29 | + "ExpectedDepartureTime": "2026-01-15T08:34:00Z", | |
| 30 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | |
| 31 | + "DepartureStatus": "ontime" | |
| 32 | + } | |
| 33 | + } | |
| 34 | + } | |
| 35 | + ] | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "MonitoredStopVisit": [ | |
| 39 | + { | |
| 40 | + "RecordedAtTime": "2026-01-15T08:29:34Z", | |
| 41 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 42 | + "MonitoredVehicleJourney": { | |
| 43 | + "LineRef": { "value": "IDFM:C1099" }, | |
| 44 | + "DirectionRef": { "value": "IDFM:1099D" }, | |
| 45 | + "DestinationRef": { "value": "IDFM:1099DST" }, | |
| 46 | + "MonitoredCall": { | |
| 47 | + "ExpectedDepartureTime": "2026-01-15T08:36:00Z", | |
| 48 | + "AimedDepartureTime": "2026-01-15T08:36:00Z", | |
| 49 | + "DepartureStatus": "ontime" | |
| 50 | + } | |
| 51 | + } | |
| 52 | + }, | |
| 53 | + { | |
| 54 | + "RecordedAtTime": "2026-01-15T08:29:33Z", | |
| 55 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 56 | + "MonitoredVehicleJourney": { | |
| 57 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 58 | + "MonitoredCall": { | |
| 59 | + "ExpectedDepartureTime": "2026-01-15T08:42:00Z", | |
| 60 | + "DepartureStatus": "ontime" | |
| 61 | + } | |
| 62 | + } | |
| 63 | + } | |
| 64 | + ] | |
| 65 | + } | |
| 66 | + ] | |
| 67 | + } | |
| 68 | + } | |
| 69 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "StopMonitoringDelivery": [ | ||
| 5 | + { | ||
| 6 | + "MonitoredStopVisit": [ | ||
| 7 | + { | ||
| 8 | + "RecordedAtTime": "2026-01-15T08:29:36Z", | ||
| 9 | + "MonitoringRef": { "value": "IDFM:SP:1099" }, | ||
| 10 | + "MonitoredVehicleJourney": { | ||
| 11 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 12 | + "DirectionRef": { "value": "IDFM:1099D" }, | ||
| 13 | + "DestinationRef": { "value": "IDFM:1099DST" }, | ||
| 14 | + "MonitoredCall": { | ||
| 15 | + "ExpectedDepartureTime": "2026-01-15T08:31:00Z", | ||
| 16 | + "AimedDepartureTime": "2026-01-15T08:31:00Z", | ||
| 17 | + "DepartureStatus": "ontime" | ||
| 18 | + } | ||
| 19 | + } | ||
| 20 | + }, | ||
| 21 | + { | ||
| 22 | + "RecordedAtTime": "2026-01-15T08:29:35Z", | ||
| 23 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 24 | + "MonitoredVehicleJourney": { | ||
| 25 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 26 | + "DirectionRef": { "value": "IDFM:1001D" }, | ||
| 27 | + "DestinationRef": { "value": "IDFM:1001DST" }, | ||
| 28 | + "MonitoredCall": { | ||
| 29 | + "ExpectedDepartureTime": "2026-01-15T08:34:00Z", | ||
| 30 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 31 | + "DepartureStatus": "ontime" | ||
| 32 | + } | ||
| 33 | + } | ||
| 34 | + } | ||
| 35 | + ] | ||
| 36 | + }, | ||
| 37 | + { | ||
| 38 | + "MonitoredStopVisit": [ | ||
| 39 | + { | ||
| 40 | + "RecordedAtTime": "2026-01-15T08:29:34Z", | ||
| 41 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 42 | + "MonitoredVehicleJourney": { | ||
| 43 | + "LineRef": { "value": "IDFM:C1099" }, | ||
| 44 | + "DirectionRef": { "value": "IDFM:1099D" }, | ||
| 45 | + "DestinationRef": { "value": "IDFM:1099DST" }, | ||
| 46 | + "MonitoredCall": { | ||
| 47 | + "ExpectedDepartureTime": "2026-01-15T08:36:00Z", | ||
| 48 | + "AimedDepartureTime": "2026-01-15T08:36:00Z", | ||
| 49 | + "DepartureStatus": "ontime" | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + }, | ||
| 53 | + { | ||
| 54 | + "RecordedAtTime": "2026-01-15T08:29:33Z", | ||
| 55 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 56 | + "MonitoredVehicleJourney": { | ||
| 57 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 58 | + "MonitoredCall": { | ||
| 59 | + "ExpectedDepartureTime": "2026-01-15T08:42:00Z", | ||
| 60 | + "DepartureStatus": "ontime" | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + } | ||
| 64 | + ] | ||
| 65 | + } | ||
| 66 | + ] | ||
| 67 | + } | ||
| 68 | + } | ||
| 69 | +} | ||
added
fixtures/departures/prim/rer.json +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:33:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:32:30Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:43Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:47:00Z", | |
| 32 | + "AimedDepartureTime": "2026-01-15T08:45:00Z", | |
| 33 | + "DepartureStatus": "ontime" | |
| 34 | + } | |
| 35 | + } | |
| 36 | + } | |
| 37 | + ] | |
| 38 | + } | |
| 39 | + ] | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:32:30Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:43Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:47:00Z", | ||
| 32 | + "AimedDepartureTime": "2026-01-15T08:45:00Z", | ||
| 33 | + "DepartureStatus": "ontime" | ||
| 34 | + } | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + ] | ||
| 38 | + } | ||
| 39 | + ] | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | +} | ||
added
fixtures/departures/prim/scheduled-only.json +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:56Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:41Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C5001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:5001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "AimedDepartureTime": "2026-01-15T08:44:00Z" | |
| 18 | + } | |
| 19 | + } | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "RecordedAtTime": "2026-01-15T08:29:39Z", | |
| 23 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | |
| 24 | + "MonitoredVehicleJourney": { | |
| 25 | + "LineRef": { "value": "IDFM:C5001" }, | |
| 26 | + "DirectionRef": { "value": "IDFM:5001D" }, | |
| 27 | + "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 28 | + "MonitoredCall": { | |
| 29 | + "AimedDepartureTime": "2026-01-15T08:58:00Z" | |
| 30 | + } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + ] | |
| 34 | + } | |
| 35 | + ] | |
| 36 | + } | |
| 37 | + } | |
| 38 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:56Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:41Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C5001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:5001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:5001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "AimedDepartureTime": "2026-01-15T08:44:00Z" | ||
| 18 | + } | ||
| 19 | + } | ||
| 20 | + }, | ||
| 21 | + { | ||
| 22 | + "RecordedAtTime": "2026-01-15T08:29:39Z", | ||
| 23 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | ||
| 24 | + "MonitoredVehicleJourney": { | ||
| 25 | + "LineRef": { "value": "IDFM:C5001" }, | ||
| 26 | + "DirectionRef": { "value": "IDFM:5001D" }, | ||
| 27 | + "DestinationRef": { "value": "IDFM:5001DST" }, | ||
| 28 | + "MonitoredCall": { | ||
| 29 | + "AimedDepartureTime": "2026-01-15T08:58:00Z" | ||
| 30 | + } | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | + ] | ||
| 34 | + } | ||
| 35 | + ] | ||
| 36 | + } | ||
| 37 | + } | ||
| 38 | +} | ||
added
fixtures/departures/prim/tram.json +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:47Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C3001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:3001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:36:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:35:00Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + } | |
| 23 | + ] | |
| 24 | + } | |
| 25 | + ] | |
| 26 | + } | |
| 27 | + } | |
| 28 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:47Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C3001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:3001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:3001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:36:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:35:00Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + } | ||
| 23 | + ] | ||
| 24 | + } | ||
| 25 | + ] | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | +} | ||
added
fixtures/departures/prim/transilien.json +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:56Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:41Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C5001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:5001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:41:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:40:00Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:39Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C5001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:5001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:55:00Z", | |
| 32 | + "AimedDepartureTime": "2026-01-15T08:54:00Z", | |
| 33 | + "DepartureStatus": "ontime" | |
| 34 | + } | |
| 35 | + } | |
| 36 | + } | |
| 37 | + ] | |
| 38 | + } | |
| 39 | + ] | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:56Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:41Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C5001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:5001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:5001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:41:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:40:00Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:39Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C5001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:5001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:5001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:55:00Z", | ||
| 32 | + "AimedDepartureTime": "2026-01-15T08:54:00Z", | ||
| 33 | + "DepartureStatus": "ontime" | ||
| 34 | + } | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + ] | ||
| 38 | + } | ||
| 39 | + ] | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | +} | ||
added
packages/backend/src/departure-endpoint.ts +501 -0 | new file mode 100644 | ||
| @@ -0,0 +1,501 @@ | ||
| 1 | +import { | |
| 2 | + createServer, | |
| 3 | + type IncomingMessage, | |
| 4 | + type Server, | |
| 5 | + type ServerResponse, | |
| 6 | +} from "node:http"; | |
| 7 | +import { | |
| 8 | + SCHEMA_VERSION, | |
| 9 | + utf8Bytes, | |
| 10 | + type ErrorResult, | |
| 11 | + type ErrorCode, | |
| 12 | +} from "../../contracts/src/index.ts"; | |
| 13 | +import type { CatalogReader } from "./catalog.ts"; | |
| 14 | +import { | |
| 15 | + createPrimDepartureResolver, | |
| 16 | + type PrimDepartureRelayRequest, | |
| 17 | + type UpstreamLimiter, | |
| 18 | +} from "./departure-service.ts"; | |
| 19 | +import { | |
| 20 | + createDepartureRuntimeState, | |
| 21 | + handleDepartureHttpRequest, | |
| 22 | + type DepartureClock, | |
| 23 | + type DepartureHandlerDependencies, | |
| 24 | + type DepartureHttpStatus, | |
| 25 | + type DepartureMetrics, | |
| 26 | +} from "./departures.ts"; | |
| 27 | +import { | |
| 28 | + relayPrimRequest, | |
| 29 | + type PrimFetch, | |
| 30 | + type PrimRelayDependencies, | |
| 31 | + type RelayLogger, | |
| 32 | + type RelayTimer, | |
| 33 | +} from "./index.ts"; | |
| 34 | + | |
| 35 | +export const DEPARTURE_ENDPOINT_LIMITS = Object.freeze({ | |
| 36 | + requestBodyBytes: 2_048, | |
| 37 | + totalHeaderBytes: 2_048, | |
| 38 | +} as const); | |
| 39 | + | |
| 40 | +export type DepartureRawHeader = readonly [name: string, value: string]; | |
| 41 | + | |
| 42 | +export type DepartureEndpointBody = | |
| 43 | + | Uint8Array | |
| 44 | + | ReadableStream<Uint8Array> | |
| 45 | + | AsyncIterable<Uint8Array>; | |
| 46 | + | |
| 47 | +export interface DepartureEndpointRequest { | |
| 48 | + readonly method: string; | |
| 49 | + readonly path: string; | |
| 50 | + readonly headers: readonly DepartureRawHeader[]; | |
| 51 | + readonly body: DepartureEndpointBody; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export interface DepartureEndpointResponse { | |
| 55 | + readonly status: DepartureHttpStatus; | |
| 56 | + readonly headers: Readonly<{ "content-type": "application/json" }>; | |
| 57 | + readonly body: Uint8Array; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export interface DepartureEndpointDependencies { | |
| 61 | + readonly catalog: CatalogReader; | |
| 62 | + readonly fetch: PrimFetch; | |
| 63 | + readonly metrics?: DepartureMetrics; | |
| 64 | + readonly clock?: DepartureClock; | |
| 65 | + readonly timer?: RelayTimer; | |
| 66 | + readonly logger?: RelayLogger; | |
| 67 | + readonly limiter?: UpstreamLimiter; | |
| 68 | +} | |
| 69 | + | |
| 70 | +export type DepartureEndpoint = ( | |
| 71 | + request: DepartureEndpointRequest, | |
| 72 | +) => Promise<DepartureEndpointResponse>; | |
| 73 | + | |
| 74 | +interface ParsedHeaders { | |
| 75 | + readonly authorization: unknown; | |
| 76 | + readonly contentLength?: number; | |
| 77 | +} | |
| 78 | + | |
| 79 | +const JSON_HEADERS = Object.freeze({ "content-type": "application/json" } as const); | |
| 80 | +const JSON_ENCODER = new TextEncoder(); | |
| 81 | +const JSON_DECODER = new TextDecoder("utf-8", { fatal: true }); | |
| 82 | +const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; | |
| 83 | +const CONTENT_LENGTH = /^[0-9]+$/u; | |
| 84 | +const INVALID_AUTHORIZATION = Symbol("invalid-authorization"); | |
| 85 | +const INVALID_REQUEST_ID = "invalid-request"; | |
| 86 | +const UINT32_MAX = 0xffff_ffff; | |
| 87 | + | |
| 88 | +function safeNowMilliseconds(clock: DepartureClock | undefined): number { | |
| 89 | + try { | |
| 90 | + const value = clock?.() ?? Date.now(); | |
| 91 | + if (Number.isFinite(value) && value >= 0) return value; | |
| 92 | + } catch { | |
| 93 | + // A boundary error must still produce a stable public response. | |
| 94 | + } | |
| 95 | + return Date.now(); | |
| 96 | +} | |
| 97 | + | |
| 98 | +function jsonResponse( | |
| 99 | + status: DepartureHttpStatus, | |
| 100 | + body: object, | |
| 101 | +): DepartureEndpointResponse { | |
| 102 | + return { | |
| 103 | + status, | |
| 104 | + headers: JSON_HEADERS, | |
| 105 | + body: JSON_ENCODER.encode(JSON.stringify(body)), | |
| 106 | + }; | |
| 107 | +} | |
| 108 | + | |
| 109 | +function errorResponse( | |
| 110 | + status: DepartureHttpStatus, | |
| 111 | + code: ErrorCode, | |
| 112 | + clock: DepartureClock | undefined, | |
| 113 | +): DepartureEndpointResponse { | |
| 114 | + const body: ErrorResult = { | |
| 115 | + schemaVersion: SCHEMA_VERSION, | |
| 116 | + requestId: INVALID_REQUEST_ID, | |
| 117 | + code, | |
| 118 | + occurredAt: Math.min(UINT32_MAX, Math.floor(safeNowMilliseconds(clock) / 1_000)), | |
| 119 | + }; | |
| 120 | + return jsonResponse(status, body); | |
| 121 | +} | |
| 122 | + | |
| 123 | +function parseHeaders(headers: readonly DepartureRawHeader[]): ParsedHeaders | undefined { | |
| 124 | + let totalBytes = 2; | |
| 125 | + let authorization: unknown = undefined; | |
| 126 | + let authorizationCount = 0; | |
| 127 | + let contentLength: number | undefined; | |
| 128 | + let contentLengthCount = 0; | |
| 129 | + | |
| 130 | + try { | |
| 131 | + for (const header of headers) { | |
| 132 | + if (!Array.isArray(header) || header.length !== 2) return undefined; | |
| 133 | + const [name, value] = header; | |
| 134 | + if (typeof name !== "string" || typeof value !== "string") return undefined; | |
| 135 | + | |
| 136 | + totalBytes += utf8Bytes(name) + 2 + utf8Bytes(value) + 2; | |
| 137 | + if (totalBytes > DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes) return undefined; | |
| 138 | + if (!HEADER_NAME.test(name) || /[\0\r\n]/u.test(value)) return undefined; | |
| 139 | + | |
| 140 | + const normalizedName = name.toLowerCase(); | |
| 141 | + if (normalizedName === "authorization") { | |
| 142 | + authorizationCount += 1; | |
| 143 | + authorization = authorizationCount === 1 ? value : INVALID_AUTHORIZATION; | |
| 144 | + } else if (normalizedName === "content-length") { | |
| 145 | + contentLengthCount += 1; | |
| 146 | + if (contentLengthCount !== 1 || !CONTENT_LENGTH.test(value)) return undefined; | |
| 147 | + const parsed = Number(value); | |
| 148 | + if ( | |
| 149 | + !Number.isSafeInteger(parsed) | |
| 150 | + || parsed > DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes | |
| 151 | + ) { | |
| 152 | + return undefined; | |
| 153 | + } | |
| 154 | + contentLength = parsed; | |
| 155 | + } | |
| 156 | + } | |
| 157 | + } catch { | |
| 158 | + return undefined; | |
| 159 | + } | |
| 160 | + | |
| 161 | + return { | |
| 162 | + authorization, | |
| 163 | + ...(contentLength === undefined ? {} : { contentLength }), | |
| 164 | + }; | |
| 165 | +} | |
| 166 | + | |
| 167 | +function assembledBytes(chunks: readonly Uint8Array[], byteLength: number): Uint8Array { | |
| 168 | + if (chunks.length === 0) return new Uint8Array(0); | |
| 169 | + if (chunks.length === 1) return chunks[0] ?? new Uint8Array(0); | |
| 170 | + | |
| 171 | + const bytes = new Uint8Array(byteLength); | |
| 172 | + let offset = 0; | |
| 173 | + for (const chunk of chunks) { | |
| 174 | + bytes.set(chunk, offset); | |
| 175 | + offset += chunk.byteLength; | |
| 176 | + } | |
| 177 | + return bytes; | |
| 178 | +} | |
| 179 | + | |
| 180 | +function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): void { | |
| 181 | + try { | |
| 182 | + void reader.cancel().catch(() => undefined); | |
| 183 | + } catch { | |
| 184 | + // The request rejection remains authoritative. | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +async function readStreamBody( | |
| 189 | + stream: ReadableStream<Uint8Array>, | |
| 190 | + maximum: number, | |
| 191 | +): Promise<Uint8Array | undefined> { | |
| 192 | + let reader: ReadableStreamDefaultReader<Uint8Array>; | |
| 193 | + try { | |
| 194 | + reader = stream.getReader(); | |
| 195 | + } catch { | |
| 196 | + return undefined; | |
| 197 | + } | |
| 198 | + | |
| 199 | + const chunks: Uint8Array[] = []; | |
| 200 | + let byteLength = 0; | |
| 201 | + try { | |
| 202 | + while (true) { | |
| 203 | + const item = await reader.read(); | |
| 204 | + if (item.done) break; | |
| 205 | + if (!(item.value instanceof Uint8Array)) { | |
| 206 | + cancelReader(reader); | |
| 207 | + return undefined; | |
| 208 | + } | |
| 209 | + if (item.value.byteLength > maximum - byteLength) { | |
| 210 | + cancelReader(reader); | |
| 211 | + return undefined; | |
| 212 | + } | |
| 213 | + if (item.value.byteLength === 0) continue; | |
| 214 | + byteLength += item.value.byteLength; | |
| 215 | + chunks.push(item.value); | |
| 216 | + } | |
| 217 | + } catch { | |
| 218 | + cancelReader(reader); | |
| 219 | + return undefined; | |
| 220 | + } finally { | |
| 221 | + try { | |
| 222 | + reader.releaseLock(); | |
| 223 | + } catch { | |
| 224 | + // A failed release cannot replace the request result. | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 228 | + return assembledBytes(chunks, byteLength); | |
| 229 | +} | |
| 230 | + | |
| 231 | +function closeIterator(iterator: AsyncIterator<Uint8Array>): void { | |
| 232 | + if (typeof iterator.return !== "function") return; | |
| 233 | + try { | |
| 234 | + void Promise.resolve(iterator.return()).catch(() => undefined); | |
| 235 | + } catch { | |
| 236 | + // The request rejection remains authoritative. | |
| 237 | + } | |
| 238 | +} | |
| 239 | + | |
| 240 | +async function readAsyncIterableBody( | |
| 241 | + body: AsyncIterable<Uint8Array>, | |
| 242 | + maximum: number, | |
| 243 | +): Promise<Uint8Array | undefined> { | |
| 244 | + let iterator: AsyncIterator<Uint8Array>; | |
| 245 | + try { | |
| 246 | + iterator = body[Symbol.asyncIterator](); | |
| 247 | + } catch { | |
| 248 | + return undefined; | |
| 249 | + } | |
| 250 | + | |
| 251 | + const chunks: Uint8Array[] = []; | |
| 252 | + let byteLength = 0; | |
| 253 | + try { | |
| 254 | + while (true) { | |
| 255 | + const item = await iterator.next(); | |
| 256 | + if (item === null || typeof item !== "object") { | |
| 257 | + closeIterator(iterator); | |
| 258 | + return undefined; | |
| 259 | + } | |
| 260 | + if (item.done) break; | |
| 261 | + if (!(item.value instanceof Uint8Array)) { | |
| 262 | + closeIterator(iterator); | |
| 263 | + return undefined; | |
| 264 | + } | |
| 265 | + if (item.value.byteLength > maximum - byteLength) { | |
| 266 | + closeIterator(iterator); | |
| 267 | + return undefined; | |
| 268 | + } | |
| 269 | + if (item.value.byteLength === 0) continue; | |
| 270 | + byteLength += item.value.byteLength; | |
| 271 | + chunks.push(item.value); | |
| 272 | + } | |
| 273 | + } catch { | |
| 274 | + closeIterator(iterator); | |
| 275 | + return undefined; | |
| 276 | + } | |
| 277 | + | |
| 278 | + return assembledBytes(chunks, byteLength); | |
| 279 | +} | |
| 280 | + | |
| 281 | +async function readRequestBody( | |
| 282 | + body: DepartureEndpointBody, | |
| 283 | + contentLength: number | undefined, | |
| 284 | +): Promise<Uint8Array | undefined> { | |
| 285 | + const maximum = contentLength ?? DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes; | |
| 286 | + let bytes: Uint8Array | undefined; | |
| 287 | + | |
| 288 | + if (body instanceof Uint8Array) { | |
| 289 | + bytes = body.byteLength <= maximum ? body : undefined; | |
| 290 | + } else { | |
| 291 | + try { | |
| 292 | + if (typeof body === "object" && body !== null && typeof body.getReader === "function") { | |
| 293 | + bytes = await readStreamBody(body, maximum); | |
| 294 | + } else if ( | |
| 295 | + typeof body === "object" | |
| 296 | + && body !== null | |
| 297 | + && typeof body[Symbol.asyncIterator] === "function" | |
| 298 | + ) { | |
| 299 | + bytes = await readAsyncIterableBody(body, maximum); | |
| 300 | + } | |
| 301 | + } catch { | |
| 302 | + return undefined; | |
| 303 | + } | |
| 304 | + } | |
| 305 | + | |
| 306 | + if (bytes === undefined) return undefined; | |
| 307 | + if (contentLength !== undefined && bytes.byteLength !== contentLength) return undefined; | |
| 308 | + return bytes; | |
| 309 | +} | |
| 310 | + | |
| 311 | +function parseJson(bytes: Uint8Array): unknown | undefined { | |
| 312 | + try { | |
| 313 | + return JSON.parse(JSON_DECODER.decode(bytes)) as unknown; | |
| 314 | + } catch { | |
| 315 | + return undefined; | |
| 316 | + } | |
| 317 | +} | |
| 318 | + | |
| 319 | +export function createDepartureEndpoint( | |
| 320 | + dependencies: DepartureEndpointDependencies, | |
| 321 | +): DepartureEndpoint { | |
| 322 | + const relayDependencies: PrimRelayDependencies = { | |
| 323 | + fetch: dependencies.fetch, | |
| 324 | + ...(dependencies.logger === undefined ? {} : { logger: dependencies.logger }), | |
| 325 | + ...(dependencies.timer === undefined ? {} : { timer: dependencies.timer }), | |
| 326 | + }; | |
| 327 | + | |
| 328 | + const relay = async ({ target, apiKey }: PrimDepartureRelayRequest) => { | |
| 329 | + let authorization = `Bearer ${apiKey}`; | |
| 330 | + try { | |
| 331 | + return await relayPrimRequest({ target, authorization }, relayDependencies); | |
| 332 | + } finally { | |
| 333 | + authorization = ""; | |
| 334 | + } | |
| 335 | + }; | |
| 336 | + | |
| 337 | + const resolve = createPrimDepartureResolver({ | |
| 338 | + catalog: dependencies.catalog, | |
| 339 | + relay, | |
| 340 | + ...(dependencies.clock === undefined ? {} : { clock: dependencies.clock }), | |
| 341 | + ...(dependencies.limiter === undefined ? {} : { limiter: dependencies.limiter }), | |
| 342 | + ...(dependencies.metrics === undefined ? {} : { metrics: dependencies.metrics }), | |
| 343 | + }); | |
| 344 | + const handlerDependencies: DepartureHandlerDependencies = { | |
| 345 | + resolve, | |
| 346 | + state: createDepartureRuntimeState(), | |
| 347 | + ...(dependencies.clock === undefined ? {} : { clock: dependencies.clock }), | |
| 348 | + ...(dependencies.metrics === undefined ? {} : { metrics: dependencies.metrics }), | |
| 349 | + }; | |
| 350 | + | |
| 351 | + return async (request) => { | |
| 352 | + let method: string; | |
| 353 | + let path: string; | |
| 354 | + let headers: readonly DepartureRawHeader[]; | |
| 355 | + try { | |
| 356 | + method = request.method; | |
| 357 | + path = request.path; | |
| 358 | + headers = request.headers; | |
| 359 | + } catch { | |
| 360 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | |
| 361 | + } | |
| 362 | + if (typeof method !== "string" || typeof path !== "string" || !Array.isArray(headers)) { | |
| 363 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | |
| 364 | + } | |
| 365 | + | |
| 366 | + const parsedHeaders = parseHeaders(headers); | |
| 367 | + if (parsedHeaders === undefined) { | |
| 368 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | |
| 369 | + } | |
| 370 | + | |
| 371 | + let source: DepartureEndpointBody; | |
| 372 | + try { | |
| 373 | + source = request.body; | |
| 374 | + } catch { | |
| 375 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | |
| 376 | + } | |
| 377 | + const bytes = await readRequestBody(source, parsedHeaders.contentLength); | |
| 378 | + if (bytes === undefined) { | |
| 379 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | |
| 380 | + } | |
| 381 | + | |
| 382 | + const body = parseJson(bytes); | |
| 383 | + if (body === undefined) { | |
| 384 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | |
| 385 | + } | |
| 386 | + | |
| 387 | + const response = await handleDepartureHttpRequest({ | |
| 388 | + method, | |
| 389 | + path, | |
| 390 | + authorization: parsedHeaders.authorization, | |
| 391 | + body, | |
| 392 | + }, handlerDependencies); | |
| 393 | + return jsonResponse(response.status, response.body); | |
| 394 | + }; | |
| 395 | +} | |
| 396 | + | |
| 397 | +function incomingHeaders(rawHeaders: readonly string[]): readonly DepartureRawHeader[] { | |
| 398 | + if (rawHeaders.length % 2 !== 0) return [["", ""]]; | |
| 399 | + const headers: DepartureRawHeader[] = []; | |
| 400 | + for (let index = 0; index < rawHeaders.length; index += 2) { | |
| 401 | + const name = rawHeaders[index]; | |
| 402 | + const value = rawHeaders[index + 1]; | |
| 403 | + if (name === undefined || value === undefined) return [["", ""]]; | |
| 404 | + headers.push([name, value]); | |
| 405 | + } | |
| 406 | + return headers; | |
| 407 | +} | |
| 408 | + | |
| 409 | +function writeHttpResponse( | |
| 410 | + response: ServerResponse, | |
| 411 | + result: DepartureEndpointResponse, | |
| 412 | + closeConnection = false, | |
| 413 | +): void { | |
| 414 | + if (response.destroyed) return; | |
| 415 | + try { | |
| 416 | + if (closeConnection) { | |
| 417 | + response.shouldKeepAlive = false; | |
| 418 | + response.setHeader("connection", "close"); | |
| 419 | + } | |
| 420 | + response.writeHead(result.status, result.headers); | |
| 421 | + response.end(result.body); | |
| 422 | + } catch { | |
| 423 | + response.destroy(); | |
| 424 | + } | |
| 425 | +} | |
| 426 | + | |
| 427 | +function serveIncomingRequest( | |
| 428 | + endpoint: DepartureEndpoint, | |
| 429 | + dependencies: DepartureEndpointDependencies, | |
| 430 | + request: IncomingMessage, | |
| 431 | + response: ServerResponse, | |
| 432 | + continueExpected: boolean, | |
| 433 | +): void { | |
| 434 | + const headers = incomingHeaders(request.rawHeaders); | |
| 435 | + if (parseHeaders(headers) === undefined) { | |
| 436 | + writeHttpResponse( | |
| 437 | + response, | |
| 438 | + errorResponse(400, "INVALID_SERVICE", dependencies.clock), | |
| 439 | + true, | |
| 440 | + ); | |
| 441 | + return; | |
| 442 | + } | |
| 443 | + | |
| 444 | + if (continueExpected) { | |
| 445 | + try { | |
| 446 | + response.writeContinue(); | |
| 447 | + } catch { | |
| 448 | + response.destroy(); | |
| 449 | + return; | |
| 450 | + } | |
| 451 | + } | |
| 452 | + | |
| 453 | + void endpoint({ | |
| 454 | + method: request.method ?? "", | |
| 455 | + path: request.url ?? "", | |
| 456 | + headers, | |
| 457 | + body: request as AsyncIterable<Uint8Array>, | |
| 458 | + }).then( | |
| 459 | + (result) => { | |
| 460 | + writeHttpResponse(response, result); | |
| 461 | + }, | |
| 462 | + () => { | |
| 463 | + writeHttpResponse( | |
| 464 | + response, | |
| 465 | + errorResponse(503, "SOURCE_UNAVAILABLE", dependencies.clock), | |
| 466 | + true, | |
| 467 | + ); | |
| 468 | + }, | |
| 469 | + ); | |
| 470 | +} | |
| 471 | + | |
| 472 | +export function createDepartureHttpServer( | |
| 473 | + dependencies: DepartureEndpointDependencies, | |
| 474 | +): Server { | |
| 475 | + const endpoint = createDepartureEndpoint(dependencies); | |
| 476 | + const server = createServer({ | |
| 477 | + maxHeaderSize: DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes, | |
| 478 | + }, (request, response) => { | |
| 479 | + serveIncomingRequest(endpoint, dependencies, request, response, false); | |
| 480 | + }); | |
| 481 | + server.on("checkContinue", (request, response) => { | |
| 482 | + serveIncomingRequest(endpoint, dependencies, request, response, true); | |
| 483 | + }); | |
| 484 | + | |
| 485 | + server.on("clientError", (_error, socket) => { | |
| 486 | + if (!socket.writable) return; | |
| 487 | + const result = errorResponse(400, "INVALID_SERVICE", dependencies.clock); | |
| 488 | + try { | |
| 489 | + socket.write( | |
| 490 | + `HTTP/1.1 400 Bad Request\r\n` | |
| 491 | + + `content-type: ${result.headers["content-type"]}\r\n` | |
| 492 | + + `content-length: ${result.body.byteLength}\r\n` | |
| 493 | + + "connection: close\r\n\r\n", | |
| 494 | + ); | |
| 495 | + socket.end(result.body); | |
| 496 | + } catch { | |
| 497 | + socket.destroy(); | |
| 498 | + } | |
| 499 | + }); | |
| 500 | + return server; | |
| 501 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,501 @@ | |||
| 1 | +import { | ||
| 2 | + createServer, | ||
| 3 | + type IncomingMessage, | ||
| 4 | + type Server, | ||
| 5 | + type ServerResponse, | ||
| 6 | +} from "node:http"; | ||
| 7 | +import { | ||
| 8 | + SCHEMA_VERSION, | ||
| 9 | + utf8Bytes, | ||
| 10 | + type ErrorResult, | ||
| 11 | + type ErrorCode, | ||
| 12 | +} from "../../contracts/src/index.ts"; | ||
| 13 | +import type { CatalogReader } from "./catalog.ts"; | ||
| 14 | +import { | ||
| 15 | + createPrimDepartureResolver, | ||
| 16 | + type PrimDepartureRelayRequest, | ||
| 17 | + type UpstreamLimiter, | ||
| 18 | +} from "./departure-service.ts"; | ||
| 19 | +import { | ||
| 20 | + createDepartureRuntimeState, | ||
| 21 | + handleDepartureHttpRequest, | ||
| 22 | + type DepartureClock, | ||
| 23 | + type DepartureHandlerDependencies, | ||
| 24 | + type DepartureHttpStatus, | ||
| 25 | + type DepartureMetrics, | ||
| 26 | +} from "./departures.ts"; | ||
| 27 | +import { | ||
| 28 | + relayPrimRequest, | ||
| 29 | + type PrimFetch, | ||
| 30 | + type PrimRelayDependencies, | ||
| 31 | + type RelayLogger, | ||
| 32 | + type RelayTimer, | ||
| 33 | +} from "./index.ts"; | ||
| 34 | + | ||
| 35 | +export const DEPARTURE_ENDPOINT_LIMITS = Object.freeze({ | ||
| 36 | + requestBodyBytes: 2_048, | ||
| 37 | + totalHeaderBytes: 2_048, | ||
| 38 | +} as const); | ||
| 39 | + | ||
| 40 | +export type DepartureRawHeader = readonly [name: string, value: string]; | ||
| 41 | + | ||
| 42 | +export type DepartureEndpointBody = | ||
| 43 | + | Uint8Array | ||
| 44 | + | ReadableStream<Uint8Array> | ||
| 45 | + | AsyncIterable<Uint8Array>; | ||
| 46 | + | ||
| 47 | +export interface DepartureEndpointRequest { | ||
| 48 | + readonly method: string; | ||
| 49 | + readonly path: string; | ||
| 50 | + readonly headers: readonly DepartureRawHeader[]; | ||
| 51 | + readonly body: DepartureEndpointBody; | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +export interface DepartureEndpointResponse { | ||
| 55 | + readonly status: DepartureHttpStatus; | ||
| 56 | + readonly headers: Readonly<{ "content-type": "application/json" }>; | ||
| 57 | + readonly body: Uint8Array; | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +export interface DepartureEndpointDependencies { | ||
| 61 | + readonly catalog: CatalogReader; | ||
| 62 | + readonly fetch: PrimFetch; | ||
| 63 | + readonly metrics?: DepartureMetrics; | ||
| 64 | + readonly clock?: DepartureClock; | ||
| 65 | + readonly timer?: RelayTimer; | ||
| 66 | + readonly logger?: RelayLogger; | ||
| 67 | + readonly limiter?: UpstreamLimiter; | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +export type DepartureEndpoint = ( | ||
| 71 | + request: DepartureEndpointRequest, | ||
| 72 | +) => Promise<DepartureEndpointResponse>; | ||
| 73 | + | ||
| 74 | +interface ParsedHeaders { | ||
| 75 | + readonly authorization: unknown; | ||
| 76 | + readonly contentLength?: number; | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +const JSON_HEADERS = Object.freeze({ "content-type": "application/json" } as const); | ||
| 80 | +const JSON_ENCODER = new TextEncoder(); | ||
| 81 | +const JSON_DECODER = new TextDecoder("utf-8", { fatal: true }); | ||
| 82 | +const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; | ||
| 83 | +const CONTENT_LENGTH = /^[0-9]+$/u; | ||
| 84 | +const INVALID_AUTHORIZATION = Symbol("invalid-authorization"); | ||
| 85 | +const INVALID_REQUEST_ID = "invalid-request"; | ||
| 86 | +const UINT32_MAX = 0xffff_ffff; | ||
| 87 | + | ||
| 88 | +function safeNowMilliseconds(clock: DepartureClock | undefined): number { | ||
| 89 | + try { | ||
| 90 | + const value = clock?.() ?? Date.now(); | ||
| 91 | + if (Number.isFinite(value) && value >= 0) return value; | ||
| 92 | + } catch { | ||
| 93 | + // A boundary error must still produce a stable public response. | ||
| 94 | + } | ||
| 95 | + return Date.now(); | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +function jsonResponse( | ||
| 99 | + status: DepartureHttpStatus, | ||
| 100 | + body: object, | ||
| 101 | +): DepartureEndpointResponse { | ||
| 102 | + return { | ||
| 103 | + status, | ||
| 104 | + headers: JSON_HEADERS, | ||
| 105 | + body: JSON_ENCODER.encode(JSON.stringify(body)), | ||
| 106 | + }; | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +function errorResponse( | ||
| 110 | + status: DepartureHttpStatus, | ||
| 111 | + code: ErrorCode, | ||
| 112 | + clock: DepartureClock | undefined, | ||
| 113 | +): DepartureEndpointResponse { | ||
| 114 | + const body: ErrorResult = { | ||
| 115 | + schemaVersion: SCHEMA_VERSION, | ||
| 116 | + requestId: INVALID_REQUEST_ID, | ||
| 117 | + code, | ||
| 118 | + occurredAt: Math.min(UINT32_MAX, Math.floor(safeNowMilliseconds(clock) / 1_000)), | ||
| 119 | + }; | ||
| 120 | + return jsonResponse(status, body); | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +function parseHeaders(headers: readonly DepartureRawHeader[]): ParsedHeaders | undefined { | ||
| 124 | + let totalBytes = 2; | ||
| 125 | + let authorization: unknown = undefined; | ||
| 126 | + let authorizationCount = 0; | ||
| 127 | + let contentLength: number | undefined; | ||
| 128 | + let contentLengthCount = 0; | ||
| 129 | + | ||
| 130 | + try { | ||
| 131 | + for (const header of headers) { | ||
| 132 | + if (!Array.isArray(header) || header.length !== 2) return undefined; | ||
| 133 | + const [name, value] = header; | ||
| 134 | + if (typeof name !== "string" || typeof value !== "string") return undefined; | ||
| 135 | + | ||
| 136 | + totalBytes += utf8Bytes(name) + 2 + utf8Bytes(value) + 2; | ||
| 137 | + if (totalBytes > DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes) return undefined; | ||
| 138 | + if (!HEADER_NAME.test(name) || /[\0\r\n]/u.test(value)) return undefined; | ||
| 139 | + | ||
| 140 | + const normalizedName = name.toLowerCase(); | ||
| 141 | + if (normalizedName === "authorization") { | ||
| 142 | + authorizationCount += 1; | ||
| 143 | + authorization = authorizationCount === 1 ? value : INVALID_AUTHORIZATION; | ||
| 144 | + } else if (normalizedName === "content-length") { | ||
| 145 | + contentLengthCount += 1; | ||
| 146 | + if (contentLengthCount !== 1 || !CONTENT_LENGTH.test(value)) return undefined; | ||
| 147 | + const parsed = Number(value); | ||
| 148 | + if ( | ||
| 149 | + !Number.isSafeInteger(parsed) | ||
| 150 | + || parsed > DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes | ||
| 151 | + ) { | ||
| 152 | + return undefined; | ||
| 153 | + } | ||
| 154 | + contentLength = parsed; | ||
| 155 | + } | ||
| 156 | + } | ||
| 157 | + } catch { | ||
| 158 | + return undefined; | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + return { | ||
| 162 | + authorization, | ||
| 163 | + ...(contentLength === undefined ? {} : { contentLength }), | ||
| 164 | + }; | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +function assembledBytes(chunks: readonly Uint8Array[], byteLength: number): Uint8Array { | ||
| 168 | + if (chunks.length === 0) return new Uint8Array(0); | ||
| 169 | + if (chunks.length === 1) return chunks[0] ?? new Uint8Array(0); | ||
| 170 | + | ||
| 171 | + const bytes = new Uint8Array(byteLength); | ||
| 172 | + let offset = 0; | ||
| 173 | + for (const chunk of chunks) { | ||
| 174 | + bytes.set(chunk, offset); | ||
| 175 | + offset += chunk.byteLength; | ||
| 176 | + } | ||
| 177 | + return bytes; | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): void { | ||
| 181 | + try { | ||
| 182 | + void reader.cancel().catch(() => undefined); | ||
| 183 | + } catch { | ||
| 184 | + // The request rejection remains authoritative. | ||
| 185 | + } | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +async function readStreamBody( | ||
| 189 | + stream: ReadableStream<Uint8Array>, | ||
| 190 | + maximum: number, | ||
| 191 | +): Promise<Uint8Array | undefined> { | ||
| 192 | + let reader: ReadableStreamDefaultReader<Uint8Array>; | ||
| 193 | + try { | ||
| 194 | + reader = stream.getReader(); | ||
| 195 | + } catch { | ||
| 196 | + return undefined; | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + const chunks: Uint8Array[] = []; | ||
| 200 | + let byteLength = 0; | ||
| 201 | + try { | ||
| 202 | + while (true) { | ||
| 203 | + const item = await reader.read(); | ||
| 204 | + if (item.done) break; | ||
| 205 | + if (!(item.value instanceof Uint8Array)) { | ||
| 206 | + cancelReader(reader); | ||
| 207 | + return undefined; | ||
| 208 | + } | ||
| 209 | + if (item.value.byteLength > maximum - byteLength) { | ||
| 210 | + cancelReader(reader); | ||
| 211 | + return undefined; | ||
| 212 | + } | ||
| 213 | + if (item.value.byteLength === 0) continue; | ||
| 214 | + byteLength += item.value.byteLength; | ||
| 215 | + chunks.push(item.value); | ||
| 216 | + } | ||
| 217 | + } catch { | ||
| 218 | + cancelReader(reader); | ||
| 219 | + return undefined; | ||
| 220 | + } finally { | ||
| 221 | + try { | ||
| 222 | + reader.releaseLock(); | ||
| 223 | + } catch { | ||
| 224 | + // A failed release cannot replace the request result. | ||
| 225 | + } | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + return assembledBytes(chunks, byteLength); | ||
| 229 | +} | ||
| 230 | + | ||
| 231 | +function closeIterator(iterator: AsyncIterator<Uint8Array>): void { | ||
| 232 | + if (typeof iterator.return !== "function") return; | ||
| 233 | + try { | ||
| 234 | + void Promise.resolve(iterator.return()).catch(() => undefined); | ||
| 235 | + } catch { | ||
| 236 | + // The request rejection remains authoritative. | ||
| 237 | + } | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +async function readAsyncIterableBody( | ||
| 241 | + body: AsyncIterable<Uint8Array>, | ||
| 242 | + maximum: number, | ||
| 243 | +): Promise<Uint8Array | undefined> { | ||
| 244 | + let iterator: AsyncIterator<Uint8Array>; | ||
| 245 | + try { | ||
| 246 | + iterator = body[Symbol.asyncIterator](); | ||
| 247 | + } catch { | ||
| 248 | + return undefined; | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + const chunks: Uint8Array[] = []; | ||
| 252 | + let byteLength = 0; | ||
| 253 | + try { | ||
| 254 | + while (true) { | ||
| 255 | + const item = await iterator.next(); | ||
| 256 | + if (item === null || typeof item !== "object") { | ||
| 257 | + closeIterator(iterator); | ||
| 258 | + return undefined; | ||
| 259 | + } | ||
| 260 | + if (item.done) break; | ||
| 261 | + if (!(item.value instanceof Uint8Array)) { | ||
| 262 | + closeIterator(iterator); | ||
| 263 | + return undefined; | ||
| 264 | + } | ||
| 265 | + if (item.value.byteLength > maximum - byteLength) { | ||
| 266 | + closeIterator(iterator); | ||
| 267 | + return undefined; | ||
| 268 | + } | ||
| 269 | + if (item.value.byteLength === 0) continue; | ||
| 270 | + byteLength += item.value.byteLength; | ||
| 271 | + chunks.push(item.value); | ||
| 272 | + } | ||
| 273 | + } catch { | ||
| 274 | + closeIterator(iterator); | ||
| 275 | + return undefined; | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + return assembledBytes(chunks, byteLength); | ||
| 279 | +} | ||
| 280 | + | ||
| 281 | +async function readRequestBody( | ||
| 282 | + body: DepartureEndpointBody, | ||
| 283 | + contentLength: number | undefined, | ||
| 284 | +): Promise<Uint8Array | undefined> { | ||
| 285 | + const maximum = contentLength ?? DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes; | ||
| 286 | + let bytes: Uint8Array | undefined; | ||
| 287 | + | ||
| 288 | + if (body instanceof Uint8Array) { | ||
| 289 | + bytes = body.byteLength <= maximum ? body : undefined; | ||
| 290 | + } else { | ||
| 291 | + try { | ||
| 292 | + if (typeof body === "object" && body !== null && typeof body.getReader === "function") { | ||
| 293 | + bytes = await readStreamBody(body, maximum); | ||
| 294 | + } else if ( | ||
| 295 | + typeof body === "object" | ||
| 296 | + && body !== null | ||
| 297 | + && typeof body[Symbol.asyncIterator] === "function" | ||
| 298 | + ) { | ||
| 299 | + bytes = await readAsyncIterableBody(body, maximum); | ||
| 300 | + } | ||
| 301 | + } catch { | ||
| 302 | + return undefined; | ||
| 303 | + } | ||
| 304 | + } | ||
| 305 | + | ||
| 306 | + if (bytes === undefined) return undefined; | ||
| 307 | + if (contentLength !== undefined && bytes.byteLength !== contentLength) return undefined; | ||
| 308 | + return bytes; | ||
| 309 | +} | ||
| 310 | + | ||
| 311 | +function parseJson(bytes: Uint8Array): unknown | undefined { | ||
| 312 | + try { | ||
| 313 | + return JSON.parse(JSON_DECODER.decode(bytes)) as unknown; | ||
| 314 | + } catch { | ||
| 315 | + return undefined; | ||
| 316 | + } | ||
| 317 | +} | ||
| 318 | + | ||
| 319 | +export function createDepartureEndpoint( | ||
| 320 | + dependencies: DepartureEndpointDependencies, | ||
| 321 | +): DepartureEndpoint { | ||
| 322 | + const relayDependencies: PrimRelayDependencies = { | ||
| 323 | + fetch: dependencies.fetch, | ||
| 324 | + ...(dependencies.logger === undefined ? {} : { logger: dependencies.logger }), | ||
| 325 | + ...(dependencies.timer === undefined ? {} : { timer: dependencies.timer }), | ||
| 326 | + }; | ||
| 327 | + | ||
| 328 | + const relay = async ({ target, apiKey }: PrimDepartureRelayRequest) => { | ||
| 329 | + let authorization = `Bearer ${apiKey}`; | ||
| 330 | + try { | ||
| 331 | + return await relayPrimRequest({ target, authorization }, relayDependencies); | ||
| 332 | + } finally { | ||
| 333 | + authorization = ""; | ||
| 334 | + } | ||
| 335 | + }; | ||
| 336 | + | ||
| 337 | + const resolve = createPrimDepartureResolver({ | ||
| 338 | + catalog: dependencies.catalog, | ||
| 339 | + relay, | ||
| 340 | + ...(dependencies.clock === undefined ? {} : { clock: dependencies.clock }), | ||
| 341 | + ...(dependencies.limiter === undefined ? {} : { limiter: dependencies.limiter }), | ||
| 342 | + ...(dependencies.metrics === undefined ? {} : { metrics: dependencies.metrics }), | ||
| 343 | + }); | ||
| 344 | + const handlerDependencies: DepartureHandlerDependencies = { | ||
| 345 | + resolve, | ||
| 346 | + state: createDepartureRuntimeState(), | ||
| 347 | + ...(dependencies.clock === undefined ? {} : { clock: dependencies.clock }), | ||
| 348 | + ...(dependencies.metrics === undefined ? {} : { metrics: dependencies.metrics }), | ||
| 349 | + }; | ||
| 350 | + | ||
| 351 | + return async (request) => { | ||
| 352 | + let method: string; | ||
| 353 | + let path: string; | ||
| 354 | + let headers: readonly DepartureRawHeader[]; | ||
| 355 | + try { | ||
| 356 | + method = request.method; | ||
| 357 | + path = request.path; | ||
| 358 | + headers = request.headers; | ||
| 359 | + } catch { | ||
| 360 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | ||
| 361 | + } | ||
| 362 | + if (typeof method !== "string" || typeof path !== "string" || !Array.isArray(headers)) { | ||
| 363 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | ||
| 364 | + } | ||
| 365 | + | ||
| 366 | + const parsedHeaders = parseHeaders(headers); | ||
| 367 | + if (parsedHeaders === undefined) { | ||
| 368 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | ||
| 369 | + } | ||
| 370 | + | ||
| 371 | + let source: DepartureEndpointBody; | ||
| 372 | + try { | ||
| 373 | + source = request.body; | ||
| 374 | + } catch { | ||
| 375 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | ||
| 376 | + } | ||
| 377 | + const bytes = await readRequestBody(source, parsedHeaders.contentLength); | ||
| 378 | + if (bytes === undefined) { | ||
| 379 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | ||
| 380 | + } | ||
| 381 | + | ||
| 382 | + const body = parseJson(bytes); | ||
| 383 | + if (body === undefined) { | ||
| 384 | + return errorResponse(400, "INVALID_SERVICE", dependencies.clock); | ||
| 385 | + } | ||
| 386 | + | ||
| 387 | + const response = await handleDepartureHttpRequest({ | ||
| 388 | + method, | ||
| 389 | + path, | ||
| 390 | + authorization: parsedHeaders.authorization, | ||
| 391 | + body, | ||
| 392 | + }, handlerDependencies); | ||
| 393 | + return jsonResponse(response.status, response.body); | ||
| 394 | + }; | ||
| 395 | +} | ||
| 396 | + | ||
| 397 | +function incomingHeaders(rawHeaders: readonly string[]): readonly DepartureRawHeader[] { | ||
| 398 | + if (rawHeaders.length % 2 !== 0) return [["", ""]]; | ||
| 399 | + const headers: DepartureRawHeader[] = []; | ||
| 400 | + for (let index = 0; index < rawHeaders.length; index += 2) { | ||
| 401 | + const name = rawHeaders[index]; | ||
| 402 | + const value = rawHeaders[index + 1]; | ||
| 403 | + if (name === undefined || value === undefined) return [["", ""]]; | ||
| 404 | + headers.push([name, value]); | ||
| 405 | + } | ||
| 406 | + return headers; | ||
| 407 | +} | ||
| 408 | + | ||
| 409 | +function writeHttpResponse( | ||
| 410 | + response: ServerResponse, | ||
| 411 | + result: DepartureEndpointResponse, | ||
| 412 | + closeConnection = false, | ||
| 413 | +): void { | ||
| 414 | + if (response.destroyed) return; | ||
| 415 | + try { | ||
| 416 | + if (closeConnection) { | ||
| 417 | + response.shouldKeepAlive = false; | ||
| 418 | + response.setHeader("connection", "close"); | ||
| 419 | + } | ||
| 420 | + response.writeHead(result.status, result.headers); | ||
| 421 | + response.end(result.body); | ||
| 422 | + } catch { | ||
| 423 | + response.destroy(); | ||
| 424 | + } | ||
| 425 | +} | ||
| 426 | + | ||
| 427 | +function serveIncomingRequest( | ||
| 428 | + endpoint: DepartureEndpoint, | ||
| 429 | + dependencies: DepartureEndpointDependencies, | ||
| 430 | + request: IncomingMessage, | ||
| 431 | + response: ServerResponse, | ||
| 432 | + continueExpected: boolean, | ||
| 433 | +): void { | ||
| 434 | + const headers = incomingHeaders(request.rawHeaders); | ||
| 435 | + if (parseHeaders(headers) === undefined) { | ||
| 436 | + writeHttpResponse( | ||
| 437 | + response, | ||
| 438 | + errorResponse(400, "INVALID_SERVICE", dependencies.clock), | ||
| 439 | + true, | ||
| 440 | + ); | ||
| 441 | + return; | ||
| 442 | + } | ||
| 443 | + | ||
| 444 | + if (continueExpected) { | ||
| 445 | + try { | ||
| 446 | + response.writeContinue(); | ||
| 447 | + } catch { | ||
| 448 | + response.destroy(); | ||
| 449 | + return; | ||
| 450 | + } | ||
| 451 | + } | ||
| 452 | + | ||
| 453 | + void endpoint({ | ||
| 454 | + method: request.method ?? "", | ||
| 455 | + path: request.url ?? "", | ||
| 456 | + headers, | ||
| 457 | + body: request as AsyncIterable<Uint8Array>, | ||
| 458 | + }).then( | ||
| 459 | + (result) => { | ||
| 460 | + writeHttpResponse(response, result); | ||
| 461 | + }, | ||
| 462 | + () => { | ||
| 463 | + writeHttpResponse( | ||
| 464 | + response, | ||
| 465 | + errorResponse(503, "SOURCE_UNAVAILABLE", dependencies.clock), | ||
| 466 | + true, | ||
| 467 | + ); | ||
| 468 | + }, | ||
| 469 | + ); | ||
| 470 | +} | ||
| 471 | + | ||
| 472 | +export function createDepartureHttpServer( | ||
| 473 | + dependencies: DepartureEndpointDependencies, | ||
| 474 | +): Server { | ||
| 475 | + const endpoint = createDepartureEndpoint(dependencies); | ||
| 476 | + const server = createServer({ | ||
| 477 | + maxHeaderSize: DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes, | ||
| 478 | + }, (request, response) => { | ||
| 479 | + serveIncomingRequest(endpoint, dependencies, request, response, false); | ||
| 480 | + }); | ||
| 481 | + server.on("checkContinue", (request, response) => { | ||
| 482 | + serveIncomingRequest(endpoint, dependencies, request, response, true); | ||
| 483 | + }); | ||
| 484 | + | ||
| 485 | + server.on("clientError", (_error, socket) => { | ||
| 486 | + if (!socket.writable) return; | ||
| 487 | + const result = errorResponse(400, "INVALID_SERVICE", dependencies.clock); | ||
| 488 | + try { | ||
| 489 | + socket.write( | ||
| 490 | + `HTTP/1.1 400 Bad Request\r\n` | ||
| 491 | + + `content-type: ${result.headers["content-type"]}\r\n` | ||
| 492 | + + `content-length: ${result.body.byteLength}\r\n` | ||
| 493 | + + "connection: close\r\n\r\n", | ||
| 494 | + ); | ||
| 495 | + socket.end(result.body); | ||
| 496 | + } catch { | ||
| 497 | + socket.destroy(); | ||
| 498 | + } | ||
| 499 | + }); | ||
| 500 | + return server; | ||
| 501 | +} | ||
added
packages/backend/src/departure-service.ts +227 -0 | new file mode 100644 | ||
| @@ -0,0 +1,227 @@ | ||
| 1 | +import { | |
| 2 | + SCHEMA_VERSION, | |
| 3 | + type ErrorCode, | |
| 4 | +} from "../../contracts/src/index.ts"; | |
| 5 | + | |
| 6 | +import type { CatalogReader } from "./catalog.ts"; | |
| 7 | +import { | |
| 8 | + DEPARTURE_METRIC_NAME, | |
| 9 | + type DepartureMetricName, | |
| 10 | + type DepartureMetrics, | |
| 11 | + type DepartureResolver, | |
| 12 | + type PublicDepartureData, | |
| 13 | + type PublicDepartureError, | |
| 14 | +} from "./departures.ts"; | |
| 15 | +import { | |
| 16 | + buildPrimStopMonitoringUrl, | |
| 17 | + normalizePrimDepartureResponse, | |
| 18 | + parseRetryAfterSeconds, | |
| 19 | +} from "./prim-departures.ts"; | |
| 20 | + | |
| 21 | +export interface PrimDepartureRelayRequest { | |
| 22 | + readonly target: URL; | |
| 23 | + readonly apiKey: string; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export interface PrimDepartureRelayResponse { | |
| 27 | + readonly status: number; | |
| 28 | + readonly bytes: Uint8Array; | |
| 29 | + readonly retryAfter?: string; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export type PrimDepartureRelay = ( | |
| 33 | + request: PrimDepartureRelayRequest, | |
| 34 | +) => Promise<PrimDepartureRelayResponse>; | |
| 35 | + | |
| 36 | +export interface UpstreamLimiter { | |
| 37 | + run<T>(operation: () => Promise<T>): Promise<T>; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface PrimDepartureResolverDependencies { | |
| 41 | + readonly catalog: CatalogReader; | |
| 42 | + readonly relay: PrimDepartureRelay; | |
| 43 | + readonly clock?: () => number; | |
| 44 | + readonly metrics?: DepartureMetrics; | |
| 45 | + readonly limiter?: UpstreamLimiter; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export const MAX_UPSTREAM_CALLS = 8 as const; | |
| 49 | +const UINT32_MAX = 0xffff_ffff; | |
| 50 | +const STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); | |
| 51 | + | |
| 52 | +export function createUpstreamLimiter( | |
| 53 | + maxActive = MAX_UPSTREAM_CALLS, | |
| 54 | +): UpstreamLimiter { | |
| 55 | + if (!Number.isInteger(maxActive) || maxActive < 1) { | |
| 56 | + throw new RangeError("maxActive must be a positive integer"); | |
| 57 | + } | |
| 58 | + | |
| 59 | + let active = 0; | |
| 60 | + const queue: Array<() => void> = []; | |
| 61 | + | |
| 62 | + async function acquire(): Promise<void> { | |
| 63 | + if (active < maxActive) { | |
| 64 | + active += 1; | |
| 65 | + return; | |
| 66 | + } | |
| 67 | + | |
| 68 | + await new Promise<void>((resolve) => { | |
| 69 | + queue.push(() => { | |
| 70 | + active += 1; | |
| 71 | + resolve(); | |
| 72 | + }); | |
| 73 | + }); | |
| 74 | + } | |
| 75 | + | |
| 76 | + function release(): void { | |
| 77 | + active -= 1; | |
| 78 | + queue.shift()?.(); | |
| 79 | + } | |
| 80 | + | |
| 81 | + return Object.freeze({ | |
| 82 | + async run<T>(operation: () => Promise<T>): Promise<T> { | |
| 83 | + await acquire(); | |
| 84 | + try { | |
| 85 | + return await operation(); | |
| 86 | + } finally { | |
| 87 | + release(); | |
| 88 | + } | |
| 89 | + }, | |
| 90 | + }); | |
| 91 | +} | |
| 92 | + | |
| 93 | +const PROCESS_UPSTREAM_LIMITER = createUpstreamLimiter(); | |
| 94 | + | |
| 95 | +function nowMilliseconds(clock: (() => number) | undefined): number { | |
| 96 | + try { | |
| 97 | + const value = clock?.() ?? Date.now(); | |
| 98 | + return Number.isFinite(value) && value >= 0 ? value : Date.now(); | |
| 99 | + } catch { | |
| 100 | + return Date.now(); | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +function metric( | |
| 105 | + metrics: DepartureMetrics | undefined, | |
| 106 | + name: DepartureMetricName, | |
| 107 | + value: number, | |
| 108 | +): void { | |
| 109 | + if (!Number.isFinite(value) || value < 0) return; | |
| 110 | + try { | |
| 111 | + metrics?.record(name, value); | |
| 112 | + } catch { | |
| 113 | + // Instrumentation must never affect the relay or request settlement. | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +function epochSeconds(milliseconds: number): number { | |
| 118 | + return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | |
| 119 | +} | |
| 120 | + | |
| 121 | +function publicError( | |
| 122 | + code: ErrorCode, | |
| 123 | + milliseconds: number, | |
| 124 | + retryAfterSeconds?: number, | |
| 125 | +): PublicDepartureError { | |
| 126 | + return { | |
| 127 | + schemaVersion: SCHEMA_VERSION, | |
| 128 | + code, | |
| 129 | + occurredAt: epochSeconds(milliseconds), | |
| 130 | + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), | |
| 131 | + }; | |
| 132 | +} | |
| 133 | + | |
| 134 | +interface RelayResponseEnvelope { | |
| 135 | + readonly status: number; | |
| 136 | + readonly bytes?: unknown; | |
| 137 | + readonly retryAfter?: unknown; | |
| 138 | +} | |
| 139 | + | |
| 140 | +function validRelayResponse(response: unknown): response is RelayResponseEnvelope { | |
| 141 | + if (typeof response !== "object" || response === null || !("status" in response)) { | |
| 142 | + return false; | |
| 143 | + } | |
| 144 | + const status = response.status; | |
| 145 | + return typeof status === "number" | |
| 146 | + && Number.isInteger(status) | |
| 147 | + && status >= 100 | |
| 148 | + && status <= 599; | |
| 149 | +} | |
| 150 | + | |
| 151 | +export function createPrimDepartureResolver( | |
| 152 | + dependencies: PrimDepartureResolverDependencies, | |
| 153 | +): DepartureResolver { | |
| 154 | + const limiter = dependencies.limiter ?? PROCESS_UPSTREAM_LIMITER; | |
| 155 | + | |
| 156 | + return async (request) => { | |
| 157 | + const resolution = dependencies.catalog.resolveService(request.serviceId); | |
| 158 | + if (resolution.status !== "RESOLVED") { | |
| 159 | + return publicError("INVALID_SERVICE", nowMilliseconds(dependencies.clock)); | |
| 160 | + } | |
| 161 | + | |
| 162 | + const target = buildPrimStopMonitoringUrl(resolution); | |
| 163 | + let apiKey = request.apiKey; | |
| 164 | + let response: unknown; | |
| 165 | + let completedAtMilliseconds: number | undefined; | |
| 166 | + try { | |
| 167 | + response = await limiter.run(async () => { | |
| 168 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, 1); | |
| 169 | + const startedAtMilliseconds = nowMilliseconds(dependencies.clock); | |
| 170 | + let status = 0; | |
| 171 | + try { | |
| 172 | + const relayResponse = await dependencies.relay({ target, apiKey }); | |
| 173 | + status = validRelayResponse(relayResponse) ? relayResponse.status : 0; | |
| 174 | + return relayResponse; | |
| 175 | + } finally { | |
| 176 | + completedAtMilliseconds = nowMilliseconds(dependencies.clock); | |
| 177 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, status); | |
| 178 | + metric( | |
| 179 | + dependencies.metrics, | |
| 180 | + DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, | |
| 181 | + Math.max(0, completedAtMilliseconds - startedAtMilliseconds), | |
| 182 | + ); | |
| 183 | + } | |
| 184 | + }); | |
| 185 | + } catch { | |
| 186 | + return publicError( | |
| 187 | + "SOURCE_UNAVAILABLE", | |
| 188 | + completedAtMilliseconds ?? nowMilliseconds(dependencies.clock), | |
| 189 | + ); | |
| 190 | + } finally { | |
| 191 | + apiKey = ""; | |
| 192 | + } | |
| 193 | + | |
| 194 | + const receivedAtMilliseconds = | |
| 195 | + completedAtMilliseconds ?? nowMilliseconds(dependencies.clock); | |
| 196 | + if (!validRelayResponse(response)) { | |
| 197 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | |
| 198 | + } | |
| 199 | + if (response.status === 401 || response.status === 403) { | |
| 200 | + return publicError("API_KEY_INVALID", receivedAtMilliseconds); | |
| 201 | + } | |
| 202 | + if (response.status === 429) { | |
| 203 | + return publicError( | |
| 204 | + "RATE_LIMITED", | |
| 205 | + receivedAtMilliseconds, | |
| 206 | + parseRetryAfterSeconds(response.retryAfter), | |
| 207 | + ); | |
| 208 | + } | |
| 209 | + if (response.status < 200 || response.status >= 300) { | |
| 210 | + return publicError("SOURCE_UNAVAILABLE", receivedAtMilliseconds); | |
| 211 | + } | |
| 212 | + if (!(response.bytes instanceof Uint8Array)) { | |
| 213 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | |
| 214 | + } | |
| 215 | + | |
| 216 | + try { | |
| 217 | + const payload = JSON.parse(STRICT_UTF8_DECODER.decode(response.bytes)) as unknown; | |
| 218 | + return normalizePrimDepartureResponse( | |
| 219 | + payload, | |
| 220 | + resolution, | |
| 221 | + epochSeconds(receivedAtMilliseconds), | |
| 222 | + ) as PublicDepartureData; | |
| 223 | + } catch { | |
| 224 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | |
| 225 | + } | |
| 226 | + }; | |
| 227 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,227 @@ | |||
| 1 | +import { | ||
| 2 | + SCHEMA_VERSION, | ||
| 3 | + type ErrorCode, | ||
| 4 | +} from "../../contracts/src/index.ts"; | ||
| 5 | + | ||
| 6 | +import type { CatalogReader } from "./catalog.ts"; | ||
| 7 | +import { | ||
| 8 | + DEPARTURE_METRIC_NAME, | ||
| 9 | + type DepartureMetricName, | ||
| 10 | + type DepartureMetrics, | ||
| 11 | + type DepartureResolver, | ||
| 12 | + type PublicDepartureData, | ||
| 13 | + type PublicDepartureError, | ||
| 14 | +} from "./departures.ts"; | ||
| 15 | +import { | ||
| 16 | + buildPrimStopMonitoringUrl, | ||
| 17 | + normalizePrimDepartureResponse, | ||
| 18 | + parseRetryAfterSeconds, | ||
| 19 | +} from "./prim-departures.ts"; | ||
| 20 | + | ||
| 21 | +export interface PrimDepartureRelayRequest { | ||
| 22 | + readonly target: URL; | ||
| 23 | + readonly apiKey: string; | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +export interface PrimDepartureRelayResponse { | ||
| 27 | + readonly status: number; | ||
| 28 | + readonly bytes: Uint8Array; | ||
| 29 | + readonly retryAfter?: string; | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +export type PrimDepartureRelay = ( | ||
| 33 | + request: PrimDepartureRelayRequest, | ||
| 34 | +) => Promise<PrimDepartureRelayResponse>; | ||
| 35 | + | ||
| 36 | +export interface UpstreamLimiter { | ||
| 37 | + run<T>(operation: () => Promise<T>): Promise<T>; | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +export interface PrimDepartureResolverDependencies { | ||
| 41 | + readonly catalog: CatalogReader; | ||
| 42 | + readonly relay: PrimDepartureRelay; | ||
| 43 | + readonly clock?: () => number; | ||
| 44 | + readonly metrics?: DepartureMetrics; | ||
| 45 | + readonly limiter?: UpstreamLimiter; | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +export const MAX_UPSTREAM_CALLS = 8 as const; | ||
| 49 | +const UINT32_MAX = 0xffff_ffff; | ||
| 50 | +const STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); | ||
| 51 | + | ||
| 52 | +export function createUpstreamLimiter( | ||
| 53 | + maxActive = MAX_UPSTREAM_CALLS, | ||
| 54 | +): UpstreamLimiter { | ||
| 55 | + if (!Number.isInteger(maxActive) || maxActive < 1) { | ||
| 56 | + throw new RangeError("maxActive must be a positive integer"); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + let active = 0; | ||
| 60 | + const queue: Array<() => void> = []; | ||
| 61 | + | ||
| 62 | + async function acquire(): Promise<void> { | ||
| 63 | + if (active < maxActive) { | ||
| 64 | + active += 1; | ||
| 65 | + return; | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + await new Promise<void>((resolve) => { | ||
| 69 | + queue.push(() => { | ||
| 70 | + active += 1; | ||
| 71 | + resolve(); | ||
| 72 | + }); | ||
| 73 | + }); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + function release(): void { | ||
| 77 | + active -= 1; | ||
| 78 | + queue.shift()?.(); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + return Object.freeze({ | ||
| 82 | + async run<T>(operation: () => Promise<T>): Promise<T> { | ||
| 83 | + await acquire(); | ||
| 84 | + try { | ||
| 85 | + return await operation(); | ||
| 86 | + } finally { | ||
| 87 | + release(); | ||
| 88 | + } | ||
| 89 | + }, | ||
| 90 | + }); | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +const PROCESS_UPSTREAM_LIMITER = createUpstreamLimiter(); | ||
| 94 | + | ||
| 95 | +function nowMilliseconds(clock: (() => number) | undefined): number { | ||
| 96 | + try { | ||
| 97 | + const value = clock?.() ?? Date.now(); | ||
| 98 | + return Number.isFinite(value) && value >= 0 ? value : Date.now(); | ||
| 99 | + } catch { | ||
| 100 | + return Date.now(); | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +function metric( | ||
| 105 | + metrics: DepartureMetrics | undefined, | ||
| 106 | + name: DepartureMetricName, | ||
| 107 | + value: number, | ||
| 108 | +): void { | ||
| 109 | + if (!Number.isFinite(value) || value < 0) return; | ||
| 110 | + try { | ||
| 111 | + metrics?.record(name, value); | ||
| 112 | + } catch { | ||
| 113 | + // Instrumentation must never affect the relay or request settlement. | ||
| 114 | + } | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +function epochSeconds(milliseconds: number): number { | ||
| 118 | + return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +function publicError( | ||
| 122 | + code: ErrorCode, | ||
| 123 | + milliseconds: number, | ||
| 124 | + retryAfterSeconds?: number, | ||
| 125 | +): PublicDepartureError { | ||
| 126 | + return { | ||
| 127 | + schemaVersion: SCHEMA_VERSION, | ||
| 128 | + code, | ||
| 129 | + occurredAt: epochSeconds(milliseconds), | ||
| 130 | + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), | ||
| 131 | + }; | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +interface RelayResponseEnvelope { | ||
| 135 | + readonly status: number; | ||
| 136 | + readonly bytes?: unknown; | ||
| 137 | + readonly retryAfter?: unknown; | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +function validRelayResponse(response: unknown): response is RelayResponseEnvelope { | ||
| 141 | + if (typeof response !== "object" || response === null || !("status" in response)) { | ||
| 142 | + return false; | ||
| 143 | + } | ||
| 144 | + const status = response.status; | ||
| 145 | + return typeof status === "number" | ||
| 146 | + && Number.isInteger(status) | ||
| 147 | + && status >= 100 | ||
| 148 | + && status <= 599; | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +export function createPrimDepartureResolver( | ||
| 152 | + dependencies: PrimDepartureResolverDependencies, | ||
| 153 | +): DepartureResolver { | ||
| 154 | + const limiter = dependencies.limiter ?? PROCESS_UPSTREAM_LIMITER; | ||
| 155 | + | ||
| 156 | + return async (request) => { | ||
| 157 | + const resolution = dependencies.catalog.resolveService(request.serviceId); | ||
| 158 | + if (resolution.status !== "RESOLVED") { | ||
| 159 | + return publicError("INVALID_SERVICE", nowMilliseconds(dependencies.clock)); | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + const target = buildPrimStopMonitoringUrl(resolution); | ||
| 163 | + let apiKey = request.apiKey; | ||
| 164 | + let response: unknown; | ||
| 165 | + let completedAtMilliseconds: number | undefined; | ||
| 166 | + try { | ||
| 167 | + response = await limiter.run(async () => { | ||
| 168 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, 1); | ||
| 169 | + const startedAtMilliseconds = nowMilliseconds(dependencies.clock); | ||
| 170 | + let status = 0; | ||
| 171 | + try { | ||
| 172 | + const relayResponse = await dependencies.relay({ target, apiKey }); | ||
| 173 | + status = validRelayResponse(relayResponse) ? relayResponse.status : 0; | ||
| 174 | + return relayResponse; | ||
| 175 | + } finally { | ||
| 176 | + completedAtMilliseconds = nowMilliseconds(dependencies.clock); | ||
| 177 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, status); | ||
| 178 | + metric( | ||
| 179 | + dependencies.metrics, | ||
| 180 | + DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, | ||
| 181 | + Math.max(0, completedAtMilliseconds - startedAtMilliseconds), | ||
| 182 | + ); | ||
| 183 | + } | ||
| 184 | + }); | ||
| 185 | + } catch { | ||
| 186 | + return publicError( | ||
| 187 | + "SOURCE_UNAVAILABLE", | ||
| 188 | + completedAtMilliseconds ?? nowMilliseconds(dependencies.clock), | ||
| 189 | + ); | ||
| 190 | + } finally { | ||
| 191 | + apiKey = ""; | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + const receivedAtMilliseconds = | ||
| 195 | + completedAtMilliseconds ?? nowMilliseconds(dependencies.clock); | ||
| 196 | + if (!validRelayResponse(response)) { | ||
| 197 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | ||
| 198 | + } | ||
| 199 | + if (response.status === 401 || response.status === 403) { | ||
| 200 | + return publicError("API_KEY_INVALID", receivedAtMilliseconds); | ||
| 201 | + } | ||
| 202 | + if (response.status === 429) { | ||
| 203 | + return publicError( | ||
| 204 | + "RATE_LIMITED", | ||
| 205 | + receivedAtMilliseconds, | ||
| 206 | + parseRetryAfterSeconds(response.retryAfter), | ||
| 207 | + ); | ||
| 208 | + } | ||
| 209 | + if (response.status < 200 || response.status >= 300) { | ||
| 210 | + return publicError("SOURCE_UNAVAILABLE", receivedAtMilliseconds); | ||
| 211 | + } | ||
| 212 | + if (!(response.bytes instanceof Uint8Array)) { | ||
| 213 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + try { | ||
| 217 | + const payload = JSON.parse(STRICT_UTF8_DECODER.decode(response.bytes)) as unknown; | ||
| 218 | + return normalizePrimDepartureResponse( | ||
| 219 | + payload, | ||
| 220 | + resolution, | ||
| 221 | + epochSeconds(receivedAtMilliseconds), | ||
| 222 | + ) as PublicDepartureData; | ||
| 223 | + } catch { | ||
| 224 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | ||
| 225 | + } | ||
| 226 | + }; | ||
| 227 | +} | ||
added
packages/backend/src/departures.ts +695 -0 | new file mode 100644 | ||
| @@ -0,0 +1,695 @@ | ||
| 1 | +import { | |
| 2 | + CACHE_FRESH_SECONDS, | |
| 3 | + LIMITS, | |
| 4 | + SCHEMA_VERSION, | |
| 5 | + isDepartureResult, | |
| 6 | + isErrorResult, | |
| 7 | + isPersonalApiKey, | |
| 8 | + utf8Bytes, | |
| 9 | + type Departure, | |
| 10 | + type DepartureResult, | |
| 11 | + type ErrorCode, | |
| 12 | + type ErrorResult, | |
| 13 | +} from "../../contracts/src/index.ts"; | |
| 14 | + | |
| 15 | +export const DEPARTURE_ROUTE = "/api/departures" as const; | |
| 16 | + | |
| 17 | +export interface DepartureRequestBody { | |
| 18 | + readonly schemaVersion: typeof SCHEMA_VERSION; | |
| 19 | + readonly requestId: string; | |
| 20 | + readonly favoriteId: string; | |
| 21 | + readonly serviceId: string; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export interface DepartureHandlerRequest { | |
| 25 | + readonly authorization: unknown; | |
| 26 | + readonly body: unknown; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export type PublicDepartureData = Omit<DepartureResult, "requestId" | "favoriteId">; | |
| 30 | +export type PublicDepartureError = Omit<ErrorResult, "requestId" | "favoriteId">; | |
| 31 | +export type DepartureResolverResult = PublicDepartureData | PublicDepartureError; | |
| 32 | + | |
| 33 | +export interface DepartureResolverRequest { | |
| 34 | + readonly serviceId: string; | |
| 35 | + readonly apiKey: string; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export type DepartureResolver = ( | |
| 39 | + request: DepartureResolverRequest, | |
| 40 | +) => Promise<DepartureResolverResult>; | |
| 41 | + | |
| 42 | +export type DepartureAuthorizationParser = (authorization: unknown) => string; | |
| 43 | +export type DepartureClock = () => number; | |
| 44 | + | |
| 45 | +export const DEPARTURE_METRIC_NAME = { | |
| 46 | + CACHE_HIT: "departure.cache.hit", | |
| 47 | + CACHE_MISS: "departure.cache.miss", | |
| 48 | + UPSTREAM_REQUEST: "departure.upstream.request", | |
| 49 | + UPSTREAM_STATUS: "departure.upstream.status", | |
| 50 | + UPSTREAM_LATENCY_MS: "departure.upstream.latency_ms", | |
| 51 | + NORMALIZED_RESPONSE_BYTES: "departure.normalized_response_bytes", | |
| 52 | + COALESCED_REQUEST: "departure.coalesced.request", | |
| 53 | +} as const; | |
| 54 | + | |
| 55 | +export type DepartureMetricName = | |
| 56 | + (typeof DEPARTURE_METRIC_NAME)[keyof typeof DEPARTURE_METRIC_NAME]; | |
| 57 | + | |
| 58 | +export interface DepartureMetrics { | |
| 59 | + record(name: DepartureMetricName, value: number): void; | |
| 60 | +} | |
| 61 | + | |
| 62 | +export type DepartureHandlerResponse = DepartureResult | ErrorResult; | |
| 63 | + | |
| 64 | +export type DepartureHttpStatus = 200 | 400 | 401 | 404 | 405 | 429 | 502 | 503; | |
| 65 | + | |
| 66 | +export interface DepartureHttpRequest { | |
| 67 | + readonly method: string; | |
| 68 | + readonly path: string; | |
| 69 | + readonly authorization: unknown; | |
| 70 | + readonly body: unknown; | |
| 71 | +} | |
| 72 | + | |
| 73 | +export interface DepartureHttpResponse { | |
| 74 | + readonly status: DepartureHttpStatus; | |
| 75 | + readonly body: DepartureHandlerResponse; | |
| 76 | +} | |
| 77 | + | |
| 78 | +interface DepartureBinding { | |
| 79 | + readonly requestId: string; | |
| 80 | + readonly favoriteId?: string; | |
| 81 | +} | |
| 82 | + | |
| 83 | +interface CachedDeparture { | |
| 84 | + readonly storedAtMilliseconds: number; | |
| 85 | + readonly data: PublicDepartureData; | |
| 86 | +} | |
| 87 | + | |
| 88 | +interface PendingDepartureEvent { | |
| 89 | + readonly request: DepartureRequestBody; | |
| 90 | + apiKey: string | null; | |
| 91 | + resolver: DepartureResolver | null; | |
| 92 | + clock: DepartureClock | undefined; | |
| 93 | + metrics: DepartureMetrics | undefined; | |
| 94 | + complete: ((result: DepartureHandlerResponse) => void) | null; | |
| 95 | + promoted: boolean; | |
| 96 | + settled: boolean; | |
| 97 | +} | |
| 98 | + | |
| 99 | +interface DepartureFlight { | |
| 100 | + readonly serviceId: string; | |
| 101 | + active: PendingDepartureEvent | null; | |
| 102 | + waiters: PendingDepartureEvent[]; | |
| 103 | +} | |
| 104 | + | |
| 105 | +export interface DepartureRuntimeState { | |
| 106 | + readonly cache: Map<string, CachedDeparture>; | |
| 107 | + readonly flights: Map<string, DepartureFlight>; | |
| 108 | +} | |
| 109 | + | |
| 110 | +export interface DepartureHandlerDependencies { | |
| 111 | + readonly resolve: DepartureResolver; | |
| 112 | + readonly nowMilliseconds?: DepartureClock; | |
| 113 | + readonly clock?: DepartureClock; | |
| 114 | + readonly state?: DepartureRuntimeState; | |
| 115 | + readonly metrics?: DepartureMetrics; | |
| 116 | + readonly authorizationParser?: DepartureAuthorizationParser; | |
| 117 | +} | |
| 118 | + | |
| 119 | +const DEPARTURE_REQUEST_KEYS = ["schemaVersion", "requestId", "favoriteId", "serviceId"] as const; | |
| 120 | +const PUBLIC_DEPARTURE_KEYS = [ | |
| 121 | + "schemaVersion", | |
| 122 | + "fetchedAt", | |
| 123 | + "sourceUpdatedAt", | |
| 124 | + "freshness", | |
| 125 | + "departures", | |
| 126 | +] as const; | |
| 127 | +const PUBLIC_ERROR_KEYS = [ | |
| 128 | + "schemaVersion", | |
| 129 | + "code", | |
| 130 | + "occurredAt", | |
| 131 | + "retryAfterSeconds", | |
| 132 | +] as const; | |
| 133 | +const INVALID_REQUEST_ID = "invalid-request"; | |
| 134 | +const UINT32_MAX = 0xffff_ffff; | |
| 135 | +const DEPARTURE_CACHE_FRESH_MS = CACHE_FRESH_SECONDS * 1_000; | |
| 136 | +const BEARER_PREFIX = "Bearer "; | |
| 137 | + | |
| 138 | +const DEFAULT_RUNTIME_STATE = createDepartureRuntimeState(); | |
| 139 | + | |
| 140 | +export function createDepartureRuntimeState(): DepartureRuntimeState { | |
| 141 | + return { | |
| 142 | + cache: new Map<string, CachedDeparture>(), | |
| 143 | + flights: new Map<string, DepartureFlight>(), | |
| 144 | + }; | |
| 145 | +} | |
| 146 | + | |
| 147 | +function record(value: unknown): value is Record<string, unknown> { | |
| 148 | + return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 149 | +} | |
| 150 | + | |
| 151 | +function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean { | |
| 152 | + const keys = Object.keys(value); | |
| 153 | + return keys.length === expected.length && keys.every((key) => expected.includes(key)); | |
| 154 | +} | |
| 155 | + | |
| 156 | + | |
| 157 | +function boundedId(value: unknown): value is string { | |
| 158 | + if (typeof value !== "string") return false; | |
| 159 | + const bytes = utf8Bytes(value); | |
| 160 | + return bytes >= 1 && bytes <= LIMITS.idUtf8Bytes; | |
| 161 | +} | |
| 162 | + | |
| 163 | +export function parseDepartureRequestBody(value: unknown): DepartureRequestBody | undefined { | |
| 164 | + if (!record(value)) return undefined; | |
| 165 | + | |
| 166 | + try { | |
| 167 | + if ( | |
| 168 | + !hasExactKeys(value, DEPARTURE_REQUEST_KEYS) | |
| 169 | + || value.schemaVersion !== SCHEMA_VERSION | |
| 170 | + || !boundedId(value.requestId) | |
| 171 | + || !boundedId(value.favoriteId) | |
| 172 | + || !boundedId(value.serviceId) | |
| 173 | + ) { | |
| 174 | + return undefined; | |
| 175 | + } | |
| 176 | + | |
| 177 | + return { | |
| 178 | + schemaVersion: SCHEMA_VERSION, | |
| 179 | + requestId: value.requestId, | |
| 180 | + favoriteId: value.favoriteId, | |
| 181 | + serviceId: value.serviceId, | |
| 182 | + }; | |
| 183 | + } catch { | |
| 184 | + return undefined; | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +export function isDepartureRequestBody(value: unknown): value is DepartureRequestBody { | |
| 189 | + return parseDepartureRequestBody(value) !== undefined; | |
| 190 | +} | |
| 191 | + | |
| 192 | +function departureBinding(value: unknown): DepartureBinding { | |
| 193 | + if (!record(value)) return { requestId: INVALID_REQUEST_ID }; | |
| 194 | + | |
| 195 | + try { | |
| 196 | + const requestId = boundedId(value.requestId) ? value.requestId : INVALID_REQUEST_ID; | |
| 197 | + const favoriteId = boundedId(value.favoriteId) ? value.favoriteId : undefined; | |
| 198 | + return favoriteId === undefined ? { requestId } : { requestId, favoriteId }; | |
| 199 | + } catch { | |
| 200 | + return { requestId: INVALID_REQUEST_ID }; | |
| 201 | + } | |
| 202 | +} | |
| 203 | + | |
| 204 | +function isPublicDepartureData(value: unknown): value is PublicDepartureData { | |
| 205 | + if (!record(value)) return false; | |
| 206 | + | |
| 207 | + try { | |
| 208 | + return Object.keys(value).every((key) => PUBLIC_DEPARTURE_KEYS.includes(key)) | |
| 209 | + && "schemaVersion" in value | |
| 210 | + && "fetchedAt" in value | |
| 211 | + && "freshness" in value | |
| 212 | + && "departures" in value | |
| 213 | + && value.freshness !== "STALE" | |
| 214 | + && isDepartureResult({ | |
| 215 | + ...value, | |
| 216 | + requestId: INVALID_REQUEST_ID, | |
| 217 | + favoriteId: INVALID_REQUEST_ID, | |
| 218 | + }); | |
| 219 | + } catch { | |
| 220 | + return false; | |
| 221 | + } | |
| 222 | +} | |
| 223 | + | |
| 224 | +function isPublicDepartureError(value: unknown): value is PublicDepartureError { | |
| 225 | + if (!record(value)) return false; | |
| 226 | + | |
| 227 | + try { | |
| 228 | + return Object.keys(value).every((key) => PUBLIC_ERROR_KEYS.includes(key)) | |
| 229 | + && "schemaVersion" in value | |
| 230 | + && "code" in value | |
| 231 | + && "occurredAt" in value | |
| 232 | + && isErrorResult({ ...value, requestId: INVALID_REQUEST_ID }); | |
| 233 | + } catch { | |
| 234 | + return false; | |
| 235 | + } | |
| 236 | +} | |
| 237 | + | |
| 238 | +function defaultAuthorizationParser(value: unknown): string { | |
| 239 | + if ( | |
| 240 | + typeof value !== "string" | |
| 241 | + || !value.startsWith(BEARER_PREFIX) | |
| 242 | + || value.length === BEARER_PREFIX.length | |
| 243 | + || value.charCodeAt(BEARER_PREFIX.length) === 0x20 | |
| 244 | + ) { | |
| 245 | + throw new TypeError("Invalid bearer authorization"); | |
| 246 | + } | |
| 247 | + return value.slice(BEARER_PREFIX.length); | |
| 248 | +} | |
| 249 | + | |
| 250 | +function authorizationMissing(value: unknown): boolean { | |
| 251 | + return value === undefined || value === null || value === ""; | |
| 252 | +} | |
| 253 | + | |
| 254 | +function parseAuthorization( | |
| 255 | + value: unknown, | |
| 256 | + parser: DepartureAuthorizationParser | undefined, | |
| 257 | +): string { | |
| 258 | + const apiKey = (parser ?? defaultAuthorizationParser)(value); | |
| 259 | + if (!isPersonalApiKey(apiKey)) throw new TypeError("Invalid bearer authorization"); | |
| 260 | + return apiKey; | |
| 261 | +} | |
| 262 | + | |
| 263 | +function dependencyClock(dependencies: DepartureHandlerDependencies): DepartureClock | undefined { | |
| 264 | + return dependencies.clock ?? dependencies.nowMilliseconds; | |
| 265 | +} | |
| 266 | + | |
| 267 | +function nowMilliseconds(clock: DepartureClock | undefined): number { | |
| 268 | + try { | |
| 269 | + const value = clock?.() ?? Date.now(); | |
| 270 | + return Number.isFinite(value) && value >= 0 ? value : Date.now(); | |
| 271 | + } catch { | |
| 272 | + return Date.now(); | |
| 273 | + } | |
| 274 | +} | |
| 275 | + | |
| 276 | +function occurredAt(milliseconds: number): number { | |
| 277 | + return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | |
| 278 | +} | |
| 279 | + | |
| 280 | +function stableError( | |
| 281 | + binding: DepartureBinding, | |
| 282 | + code: ErrorCode, | |
| 283 | + milliseconds: number, | |
| 284 | +): ErrorResult { | |
| 285 | + const result: ErrorResult = { | |
| 286 | + schemaVersion: SCHEMA_VERSION, | |
| 287 | + requestId: binding.requestId, | |
| 288 | + code, | |
| 289 | + occurredAt: occurredAt(milliseconds), | |
| 290 | + }; | |
| 291 | + if (binding.favoriteId !== undefined) result.favoriteId = binding.favoriteId; | |
| 292 | + return result; | |
| 293 | +} | |
| 294 | + | |
| 295 | +function publicError(code: ErrorCode, milliseconds: number): PublicDepartureError { | |
| 296 | + return { | |
| 297 | + schemaVersion: SCHEMA_VERSION, | |
| 298 | + code, | |
| 299 | + occurredAt: occurredAt(milliseconds), | |
| 300 | + }; | |
| 301 | +} | |
| 302 | + | |
| 303 | +function copyDeparture(value: Departure): Departure { | |
| 304 | + const copy: Departure = { | |
| 305 | + expectedAt: value.expectedAt, | |
| 306 | + minutes: value.minutes, | |
| 307 | + status: value.status, | |
| 308 | + }; | |
| 309 | + if (value.aimedAt !== undefined) copy.aimedAt = value.aimedAt; | |
| 310 | + if (value.nextIntervalMinutes !== undefined) copy.nextIntervalMinutes = value.nextIntervalMinutes; | |
| 311 | + return copy; | |
| 312 | +} | |
| 313 | + | |
| 314 | +function copyPublicDeparture(value: PublicDepartureData): PublicDepartureData { | |
| 315 | + const copy: PublicDepartureData = { | |
| 316 | + schemaVersion: SCHEMA_VERSION, | |
| 317 | + fetchedAt: value.fetchedAt, | |
| 318 | + freshness: value.freshness, | |
| 319 | + departures: value.departures.map(copyDeparture), | |
| 320 | + }; | |
| 321 | + if (value.sourceUpdatedAt !== undefined) copy.sourceUpdatedAt = value.sourceUpdatedAt; | |
| 322 | + return copy; | |
| 323 | +} | |
| 324 | + | |
| 325 | +function bindDeparture( | |
| 326 | + data: PublicDepartureData, | |
| 327 | + request: DepartureRequestBody, | |
| 328 | +): DepartureResult { | |
| 329 | + const result: DepartureResult = { | |
| 330 | + schemaVersion: SCHEMA_VERSION, | |
| 331 | + requestId: request.requestId, | |
| 332 | + favoriteId: request.favoriteId, | |
| 333 | + fetchedAt: data.fetchedAt, | |
| 334 | + freshness: data.freshness, | |
| 335 | + departures: data.departures.map(copyDeparture), | |
| 336 | + }; | |
| 337 | + if (data.sourceUpdatedAt !== undefined) result.sourceUpdatedAt = data.sourceUpdatedAt; | |
| 338 | + return result; | |
| 339 | +} | |
| 340 | + | |
| 341 | +function bindPublicError( | |
| 342 | + error: PublicDepartureError, | |
| 343 | + request: DepartureRequestBody, | |
| 344 | +): ErrorResult { | |
| 345 | + const result: ErrorResult = { | |
| 346 | + schemaVersion: SCHEMA_VERSION, | |
| 347 | + requestId: request.requestId, | |
| 348 | + favoriteId: request.favoriteId, | |
| 349 | + code: error.code, | |
| 350 | + occurredAt: error.occurredAt, | |
| 351 | + }; | |
| 352 | + if (error.retryAfterSeconds !== undefined) result.retryAfterSeconds = error.retryAfterSeconds; | |
| 353 | + return result; | |
| 354 | +} | |
| 355 | + | |
| 356 | +function metric( | |
| 357 | + metrics: DepartureMetrics | undefined, | |
| 358 | + name: DepartureMetricName, | |
| 359 | + value: number, | |
| 360 | +): void { | |
| 361 | + if (!Number.isFinite(value) || value < 0) return; | |
| 362 | + try { | |
| 363 | + metrics?.record(name, value); | |
| 364 | + } catch { | |
| 365 | + // Instrumentation must never affect request settlement. | |
| 366 | + } | |
| 367 | +} | |
| 368 | + | |
| 369 | +function normalizedBytes(data: PublicDepartureData): number { | |
| 370 | + return utf8Bytes(JSON.stringify(data)); | |
| 371 | +} | |
| 372 | + | |
| 373 | +function cachedDeparture( | |
| 374 | + state: DepartureRuntimeState, | |
| 375 | + serviceId: string, | |
| 376 | + milliseconds: number, | |
| 377 | +): PublicDepartureData | undefined { | |
| 378 | + const cached = state.cache.get(serviceId); | |
| 379 | + if (cached === undefined) return undefined; | |
| 380 | + | |
| 381 | + const age = milliseconds - cached.storedAtMilliseconds; | |
| 382 | + if ( | |
| 383 | + !Number.isFinite(cached.storedAtMilliseconds) | |
| 384 | + || age < 0 | |
| 385 | + || age >= DEPARTURE_CACHE_FRESH_MS | |
| 386 | + || !isPublicDepartureData(cached.data) | |
| 387 | + ) { | |
| 388 | + state.cache.delete(serviceId); | |
| 389 | + return undefined; | |
| 390 | + } | |
| 391 | + return cached.data; | |
| 392 | +} | |
| 393 | + | |
| 394 | +function settleEvent( | |
| 395 | + event: PendingDepartureEvent, | |
| 396 | + result: DepartureHandlerResponse, | |
| 397 | +): void { | |
| 398 | + if (event.settled) return; | |
| 399 | + event.settled = true; | |
| 400 | + event.apiKey = null; | |
| 401 | + event.resolver = null; | |
| 402 | + event.clock = undefined; | |
| 403 | + event.metrics = undefined; | |
| 404 | + const complete = event.complete; | |
| 405 | + event.complete = null; | |
| 406 | + complete?.(result); | |
| 407 | +} | |
| 408 | + | |
| 409 | +function removeFlight(state: DepartureRuntimeState, flight: DepartureFlight): void { | |
| 410 | + if (state.flights.get(flight.serviceId) === flight) { | |
| 411 | + state.flights.delete(flight.serviceId); | |
| 412 | + } | |
| 413 | +} | |
| 414 | + | |
| 415 | +function allFlightEvents(flight: DepartureFlight): PendingDepartureEvent[] { | |
| 416 | + const events = flight.active === null | |
| 417 | + ? flight.waiters | |
| 418 | + : [flight.active, ...flight.waiters]; | |
| 419 | + flight.active = null; | |
| 420 | + flight.waiters = []; | |
| 421 | + return events; | |
| 422 | +} | |
| 423 | + | |
| 424 | +function settleSharedError( | |
| 425 | + state: DepartureRuntimeState, | |
| 426 | + flight: DepartureFlight, | |
| 427 | + error: PublicDepartureError, | |
| 428 | +): void { | |
| 429 | + const events = allFlightEvents(flight); | |
| 430 | + removeFlight(state, flight); | |
| 431 | + for (const event of events) settleEvent(event, bindPublicError(error, event.request)); | |
| 432 | +} | |
| 433 | + | |
| 434 | +function settleSuccess( | |
| 435 | + state: DepartureRuntimeState, | |
| 436 | + flight: DepartureFlight, | |
| 437 | + data: PublicDepartureData, | |
| 438 | + storedAtMilliseconds: number, | |
| 439 | +): void { | |
| 440 | + const cached = copyPublicDeparture(data); | |
| 441 | + state.cache.set(flight.serviceId, { storedAtMilliseconds, data: cached }); | |
| 442 | + const events = allFlightEvents(flight); | |
| 443 | + removeFlight(state, flight); | |
| 444 | + const bytes = normalizedBytes(cached); | |
| 445 | + for (const event of events) { | |
| 446 | + metric(event.metrics, DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES, bytes); | |
| 447 | + settleEvent(event, bindDeparture(cached, event.request)); | |
| 448 | + } | |
| 449 | +} | |
| 450 | + | |
| 451 | + | |
| 452 | +function settleCredentialFailure( | |
| 453 | + state: DepartureRuntimeState, | |
| 454 | + flight: DepartureFlight, | |
| 455 | + active: PendingDepartureEvent, | |
| 456 | + activeKey: string, | |
| 457 | + error: PublicDepartureError, | |
| 458 | +): void { | |
| 459 | + const sameCredential: PendingDepartureEvent[] = [active]; | |
| 460 | + const remaining: PendingDepartureEvent[] = []; | |
| 461 | + | |
| 462 | + for (const waiter of flight.waiters) { | |
| 463 | + if (waiter.apiKey === activeKey) sameCredential.push(waiter); | |
| 464 | + else remaining.push(waiter); | |
| 465 | + } | |
| 466 | + | |
| 467 | + flight.active = null; | |
| 468 | + flight.waiters = remaining; | |
| 469 | + for (const event of sameCredential) { | |
| 470 | + settleEvent(event, bindPublicError(error, event.request)); | |
| 471 | + } | |
| 472 | + | |
| 473 | + const nextIndex = remaining.findIndex((event) => | |
| 474 | + !event.promoted && event.apiKey !== null && event.apiKey !== activeKey | |
| 475 | + ); | |
| 476 | + if (nextIndex < 0) { | |
| 477 | + const unsettled = allFlightEvents(flight); | |
| 478 | + removeFlight(state, flight); | |
| 479 | + const fallback = publicError("INVALID_RESPONSE", nowMilliseconds(active.clock)); | |
| 480 | + for (const event of unsettled) settleEvent(event, bindPublicError(fallback, event.request)); | |
| 481 | + return; | |
| 482 | + } | |
| 483 | + | |
| 484 | + const [next] = remaining.splice(nextIndex, 1); | |
| 485 | + if (next === undefined) return; | |
| 486 | + next.promoted = true; | |
| 487 | + flight.active = next; | |
| 488 | + runPromotedEvent(state, flight, next); | |
| 489 | +} | |
| 490 | + | |
| 491 | +function runPromotedEvent( | |
| 492 | + state: DepartureRuntimeState, | |
| 493 | + flight: DepartureFlight, | |
| 494 | + active: PendingDepartureEvent, | |
| 495 | +): void { | |
| 496 | + void (async () => { | |
| 497 | + let activeKey = active.apiKey; | |
| 498 | + const resolver = active.resolver; | |
| 499 | + active.resolver = null; | |
| 500 | + | |
| 501 | + if (activeKey === null || resolver === null) { | |
| 502 | + settleSharedError( | |
| 503 | + state, | |
| 504 | + flight, | |
| 505 | + publicError("INVALID_RESPONSE", nowMilliseconds(active.clock)), | |
| 506 | + ); | |
| 507 | + return; | |
| 508 | + } | |
| 509 | + | |
| 510 | + const resolverRequest: DepartureResolverRequest = { | |
| 511 | + serviceId: flight.serviceId, | |
| 512 | + apiKey: activeKey, | |
| 513 | + }; | |
| 514 | + | |
| 515 | + let resolved: unknown; | |
| 516 | + let failed = false; | |
| 517 | + try { | |
| 518 | + resolved = await resolver(resolverRequest); | |
| 519 | + } catch { | |
| 520 | + failed = true; | |
| 521 | + } | |
| 522 | + | |
| 523 | + const completedAt = nowMilliseconds(active.clock); | |
| 524 | + | |
| 525 | + try { | |
| 526 | + if (failed) { | |
| 527 | + settleSharedError(state, flight, publicError("SOURCE_UNAVAILABLE", completedAt)); | |
| 528 | + return; | |
| 529 | + } | |
| 530 | + | |
| 531 | + if (isPublicDepartureData(resolved)) { | |
| 532 | + settleSuccess(state, flight, resolved, completedAt); | |
| 533 | + return; | |
| 534 | + } | |
| 535 | + | |
| 536 | + if (isPublicDepartureError(resolved)) { | |
| 537 | + if (resolved.code === "API_KEY_INVALID" || resolved.code === "RATE_LIMITED") { | |
| 538 | + settleCredentialFailure(state, flight, active, activeKey, resolved); | |
| 539 | + } else { | |
| 540 | + settleSharedError(state, flight, resolved); | |
| 541 | + } | |
| 542 | + return; | |
| 543 | + } | |
| 544 | + | |
| 545 | + settleSharedError(state, flight, publicError("INVALID_RESPONSE", completedAt)); | |
| 546 | + } catch { | |
| 547 | + if (!active.settled && state.flights.get(flight.serviceId) === flight) { | |
| 548 | + settleSharedError(state, flight, publicError("SOURCE_UNAVAILABLE", completedAt)); | |
| 549 | + } | |
| 550 | + } finally { | |
| 551 | + activeKey = null; | |
| 552 | + } | |
| 553 | + })(); | |
| 554 | +} | |
| 555 | + | |
| 556 | +export async function handleDepartureRequest( | |
| 557 | + request: DepartureHandlerRequest, | |
| 558 | + dependencies: DepartureHandlerDependencies, | |
| 559 | +): Promise<DepartureHandlerResponse> { | |
| 560 | + let authorization: unknown; | |
| 561 | + let bodyValue: unknown; | |
| 562 | + try { | |
| 563 | + authorization = request.authorization; | |
| 564 | + bodyValue = request.body; | |
| 565 | + } catch { | |
| 566 | + authorization = undefined; | |
| 567 | + bodyValue = undefined; | |
| 568 | + } | |
| 569 | + | |
| 570 | + const binding = departureBinding(bodyValue); | |
| 571 | + let apiKey: string | null = null; | |
| 572 | + try { | |
| 573 | + apiKey = parseAuthorization(authorization, dependencies.authorizationParser); | |
| 574 | + } catch { | |
| 575 | + const code: ErrorCode = authorizationMissing(authorization) | |
| 576 | + ? "API_KEY_REQUIRED" | |
| 577 | + : "API_KEY_INVALID"; | |
| 578 | + return stableError(binding, code, nowMilliseconds(dependencyClock(dependencies))); | |
| 579 | + } | |
| 580 | + | |
| 581 | + const body = parseDepartureRequestBody(bodyValue); | |
| 582 | + if (body === undefined) { | |
| 583 | + apiKey = null; | |
| 584 | + return stableError( | |
| 585 | + binding, | |
| 586 | + "INVALID_SERVICE", | |
| 587 | + nowMilliseconds(dependencyClock(dependencies)), | |
| 588 | + ); | |
| 589 | + } | |
| 590 | + | |
| 591 | + const state = dependencies.state ?? DEFAULT_RUNTIME_STATE; | |
| 592 | + const clock = dependencyClock(dependencies); | |
| 593 | + const currentMilliseconds = nowMilliseconds(clock); | |
| 594 | + const cached = cachedDeparture(state, body.serviceId, currentMilliseconds); | |
| 595 | + if (cached !== undefined) { | |
| 596 | + apiKey = null; | |
| 597 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.CACHE_HIT, 1); | |
| 598 | + metric( | |
| 599 | + dependencies.metrics, | |
| 600 | + DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES, | |
| 601 | + normalizedBytes(cached), | |
| 602 | + ); | |
| 603 | + return bindDeparture(cached, body); | |
| 604 | + } | |
| 605 | + | |
| 606 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.CACHE_MISS, 1); | |
| 607 | + | |
| 608 | + let complete: ((result: DepartureHandlerResponse) => void) | null = null; | |
| 609 | + const result = new Promise<DepartureHandlerResponse>((resolve) => { | |
| 610 | + complete = resolve; | |
| 611 | + }); | |
| 612 | + const event: PendingDepartureEvent = { | |
| 613 | + request: body, | |
| 614 | + apiKey, | |
| 615 | + resolver: dependencies.resolve, | |
| 616 | + clock, | |
| 617 | + metrics: dependencies.metrics, | |
| 618 | + complete, | |
| 619 | + promoted: false, | |
| 620 | + settled: false, | |
| 621 | + }; | |
| 622 | + apiKey = null; | |
| 623 | + | |
| 624 | + const existing = state.flights.get(body.serviceId); | |
| 625 | + if (existing !== undefined) { | |
| 626 | + existing.waiters.push(event); | |
| 627 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.COALESCED_REQUEST, 1); | |
| 628 | + } else { | |
| 629 | + const flight: DepartureFlight = { | |
| 630 | + serviceId: body.serviceId, | |
| 631 | + active: event, | |
| 632 | + waiters: [], | |
| 633 | + }; | |
| 634 | + event.promoted = true; | |
| 635 | + state.flights.set(body.serviceId, flight); | |
| 636 | + runPromotedEvent(state, flight, event); | |
| 637 | + } | |
| 638 | + | |
| 639 | + return result; | |
| 640 | +} | |
| 641 | + | |
| 642 | +export function departureHttpStatus(response: DepartureHandlerResponse): DepartureHttpStatus { | |
| 643 | + if (isDepartureResult(response)) return 200; | |
| 644 | + switch (response.code) { | |
| 645 | + case "API_KEY_REQUIRED": | |
| 646 | + case "API_KEY_INVALID": | |
| 647 | + return 401; | |
| 648 | + case "INVALID_SERVICE": | |
| 649 | + return 400; | |
| 650 | + case "RATE_LIMITED": | |
| 651 | + return 429; | |
| 652 | + case "SOURCE_UNAVAILABLE": | |
| 653 | + return 503; | |
| 654 | + case "INVALID_RESPONSE": | |
| 655 | + return 502; | |
| 656 | + } | |
| 657 | +} | |
| 658 | + | |
| 659 | +export async function handleDepartureHttpRequest( | |
| 660 | + request: DepartureHttpRequest, | |
| 661 | + dependencies: DepartureHandlerDependencies, | |
| 662 | +): Promise<DepartureHttpResponse> { | |
| 663 | + let method: string; | |
| 664 | + let path: string; | |
| 665 | + let authorization: unknown; | |
| 666 | + let body: unknown; | |
| 667 | + try { | |
| 668 | + method = request.method; | |
| 669 | + path = request.path; | |
| 670 | + authorization = request.authorization; | |
| 671 | + body = request.body; | |
| 672 | + } catch { | |
| 673 | + method = ""; | |
| 674 | + path = ""; | |
| 675 | + authorization = undefined; | |
| 676 | + body = undefined; | |
| 677 | + } | |
| 678 | + | |
| 679 | + const binding = departureBinding(body); | |
| 680 | + if (path !== DEPARTURE_ROUTE) { | |
| 681 | + return { | |
| 682 | + status: 404, | |
| 683 | + body: stableError(binding, "INVALID_SERVICE", nowMilliseconds(dependencyClock(dependencies))), | |
| 684 | + }; | |
| 685 | + } | |
| 686 | + if (method !== "POST") { | |
| 687 | + return { | |
| 688 | + status: 405, | |
| 689 | + body: stableError(binding, "INVALID_SERVICE", nowMilliseconds(dependencyClock(dependencies))), | |
| 690 | + }; | |
| 691 | + } | |
| 692 | + | |
| 693 | + const response = await handleDepartureRequest({ authorization, body }, dependencies); | |
| 694 | + return { status: departureHttpStatus(response), body: response }; | |
| 695 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,695 @@ | |||
| 1 | +import { | ||
| 2 | + CACHE_FRESH_SECONDS, | ||
| 3 | + LIMITS, | ||
| 4 | + SCHEMA_VERSION, | ||
| 5 | + isDepartureResult, | ||
| 6 | + isErrorResult, | ||
| 7 | + isPersonalApiKey, | ||
| 8 | + utf8Bytes, | ||
| 9 | + type Departure, | ||
| 10 | + type DepartureResult, | ||
| 11 | + type ErrorCode, | ||
| 12 | + type ErrorResult, | ||
| 13 | +} from "../../contracts/src/index.ts"; | ||
| 14 | + | ||
| 15 | +export const DEPARTURE_ROUTE = "/api/departures" as const; | ||
| 16 | + | ||
| 17 | +export interface DepartureRequestBody { | ||
| 18 | + readonly schemaVersion: typeof SCHEMA_VERSION; | ||
| 19 | + readonly requestId: string; | ||
| 20 | + readonly favoriteId: string; | ||
| 21 | + readonly serviceId: string; | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +export interface DepartureHandlerRequest { | ||
| 25 | + readonly authorization: unknown; | ||
| 26 | + readonly body: unknown; | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +export type PublicDepartureData = Omit<DepartureResult, "requestId" | "favoriteId">; | ||
| 30 | +export type PublicDepartureError = Omit<ErrorResult, "requestId" | "favoriteId">; | ||
| 31 | +export type DepartureResolverResult = PublicDepartureData | PublicDepartureError; | ||
| 32 | + | ||
| 33 | +export interface DepartureResolverRequest { | ||
| 34 | + readonly serviceId: string; | ||
| 35 | + readonly apiKey: string; | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +export type DepartureResolver = ( | ||
| 39 | + request: DepartureResolverRequest, | ||
| 40 | +) => Promise<DepartureResolverResult>; | ||
| 41 | + | ||
| 42 | +export type DepartureAuthorizationParser = (authorization: unknown) => string; | ||
| 43 | +export type DepartureClock = () => number; | ||
| 44 | + | ||
| 45 | +export const DEPARTURE_METRIC_NAME = { | ||
| 46 | + CACHE_HIT: "departure.cache.hit", | ||
| 47 | + CACHE_MISS: "departure.cache.miss", | ||
| 48 | + UPSTREAM_REQUEST: "departure.upstream.request", | ||
| 49 | + UPSTREAM_STATUS: "departure.upstream.status", | ||
| 50 | + UPSTREAM_LATENCY_MS: "departure.upstream.latency_ms", | ||
| 51 | + NORMALIZED_RESPONSE_BYTES: "departure.normalized_response_bytes", | ||
| 52 | + COALESCED_REQUEST: "departure.coalesced.request", | ||
| 53 | +} as const; | ||
| 54 | + | ||
| 55 | +export type DepartureMetricName = | ||
| 56 | + (typeof DEPARTURE_METRIC_NAME)[keyof typeof DEPARTURE_METRIC_NAME]; | ||
| 57 | + | ||
| 58 | +export interface DepartureMetrics { | ||
| 59 | + record(name: DepartureMetricName, value: number): void; | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +export type DepartureHandlerResponse = DepartureResult | ErrorResult; | ||
| 63 | + | ||
| 64 | +export type DepartureHttpStatus = 200 | 400 | 401 | 404 | 405 | 429 | 502 | 503; | ||
| 65 | + | ||
| 66 | +export interface DepartureHttpRequest { | ||
| 67 | + readonly method: string; | ||
| 68 | + readonly path: string; | ||
| 69 | + readonly authorization: unknown; | ||
| 70 | + readonly body: unknown; | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +export interface DepartureHttpResponse { | ||
| 74 | + readonly status: DepartureHttpStatus; | ||
| 75 | + readonly body: DepartureHandlerResponse; | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +interface DepartureBinding { | ||
| 79 | + readonly requestId: string; | ||
| 80 | + readonly favoriteId?: string; | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +interface CachedDeparture { | ||
| 84 | + readonly storedAtMilliseconds: number; | ||
| 85 | + readonly data: PublicDepartureData; | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +interface PendingDepartureEvent { | ||
| 89 | + readonly request: DepartureRequestBody; | ||
| 90 | + apiKey: string | null; | ||
| 91 | + resolver: DepartureResolver | null; | ||
| 92 | + clock: DepartureClock | undefined; | ||
| 93 | + metrics: DepartureMetrics | undefined; | ||
| 94 | + complete: ((result: DepartureHandlerResponse) => void) | null; | ||
| 95 | + promoted: boolean; | ||
| 96 | + settled: boolean; | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +interface DepartureFlight { | ||
| 100 | + readonly serviceId: string; | ||
| 101 | + active: PendingDepartureEvent | null; | ||
| 102 | + waiters: PendingDepartureEvent[]; | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +export interface DepartureRuntimeState { | ||
| 106 | + readonly cache: Map<string, CachedDeparture>; | ||
| 107 | + readonly flights: Map<string, DepartureFlight>; | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +export interface DepartureHandlerDependencies { | ||
| 111 | + readonly resolve: DepartureResolver; | ||
| 112 | + readonly nowMilliseconds?: DepartureClock; | ||
| 113 | + readonly clock?: DepartureClock; | ||
| 114 | + readonly state?: DepartureRuntimeState; | ||
| 115 | + readonly metrics?: DepartureMetrics; | ||
| 116 | + readonly authorizationParser?: DepartureAuthorizationParser; | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +const DEPARTURE_REQUEST_KEYS = ["schemaVersion", "requestId", "favoriteId", "serviceId"] as const; | ||
| 120 | +const PUBLIC_DEPARTURE_KEYS = [ | ||
| 121 | + "schemaVersion", | ||
| 122 | + "fetchedAt", | ||
| 123 | + "sourceUpdatedAt", | ||
| 124 | + "freshness", | ||
| 125 | + "departures", | ||
| 126 | +] as const; | ||
| 127 | +const PUBLIC_ERROR_KEYS = [ | ||
| 128 | + "schemaVersion", | ||
| 129 | + "code", | ||
| 130 | + "occurredAt", | ||
| 131 | + "retryAfterSeconds", | ||
| 132 | +] as const; | ||
| 133 | +const INVALID_REQUEST_ID = "invalid-request"; | ||
| 134 | +const UINT32_MAX = 0xffff_ffff; | ||
| 135 | +const DEPARTURE_CACHE_FRESH_MS = CACHE_FRESH_SECONDS * 1_000; | ||
| 136 | +const BEARER_PREFIX = "Bearer "; | ||
| 137 | + | ||
| 138 | +const DEFAULT_RUNTIME_STATE = createDepartureRuntimeState(); | ||
| 139 | + | ||
| 140 | +export function createDepartureRuntimeState(): DepartureRuntimeState { | ||
| 141 | + return { | ||
| 142 | + cache: new Map<string, CachedDeparture>(), | ||
| 143 | + flights: new Map<string, DepartureFlight>(), | ||
| 144 | + }; | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +function record(value: unknown): value is Record<string, unknown> { | ||
| 148 | + return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean { | ||
| 152 | + const keys = Object.keys(value); | ||
| 153 | + return keys.length === expected.length && keys.every((key) => expected.includes(key)); | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | + | ||
| 157 | +function boundedId(value: unknown): value is string { | ||
| 158 | + if (typeof value !== "string") return false; | ||
| 159 | + const bytes = utf8Bytes(value); | ||
| 160 | + return bytes >= 1 && bytes <= LIMITS.idUtf8Bytes; | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +export function parseDepartureRequestBody(value: unknown): DepartureRequestBody | undefined { | ||
| 164 | + if (!record(value)) return undefined; | ||
| 165 | + | ||
| 166 | + try { | ||
| 167 | + if ( | ||
| 168 | + !hasExactKeys(value, DEPARTURE_REQUEST_KEYS) | ||
| 169 | + || value.schemaVersion !== SCHEMA_VERSION | ||
| 170 | + || !boundedId(value.requestId) | ||
| 171 | + || !boundedId(value.favoriteId) | ||
| 172 | + || !boundedId(value.serviceId) | ||
| 173 | + ) { | ||
| 174 | + return undefined; | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + return { | ||
| 178 | + schemaVersion: SCHEMA_VERSION, | ||
| 179 | + requestId: value.requestId, | ||
| 180 | + favoriteId: value.favoriteId, | ||
| 181 | + serviceId: value.serviceId, | ||
| 182 | + }; | ||
| 183 | + } catch { | ||
| 184 | + return undefined; | ||
| 185 | + } | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +export function isDepartureRequestBody(value: unknown): value is DepartureRequestBody { | ||
| 189 | + return parseDepartureRequestBody(value) !== undefined; | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +function departureBinding(value: unknown): DepartureBinding { | ||
| 193 | + if (!record(value)) return { requestId: INVALID_REQUEST_ID }; | ||
| 194 | + | ||
| 195 | + try { | ||
| 196 | + const requestId = boundedId(value.requestId) ? value.requestId : INVALID_REQUEST_ID; | ||
| 197 | + const favoriteId = boundedId(value.favoriteId) ? value.favoriteId : undefined; | ||
| 198 | + return favoriteId === undefined ? { requestId } : { requestId, favoriteId }; | ||
| 199 | + } catch { | ||
| 200 | + return { requestId: INVALID_REQUEST_ID }; | ||
| 201 | + } | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +function isPublicDepartureData(value: unknown): value is PublicDepartureData { | ||
| 205 | + if (!record(value)) return false; | ||
| 206 | + | ||
| 207 | + try { | ||
| 208 | + return Object.keys(value).every((key) => PUBLIC_DEPARTURE_KEYS.includes(key)) | ||
| 209 | + && "schemaVersion" in value | ||
| 210 | + && "fetchedAt" in value | ||
| 211 | + && "freshness" in value | ||
| 212 | + && "departures" in value | ||
| 213 | + && value.freshness !== "STALE" | ||
| 214 | + && isDepartureResult({ | ||
| 215 | + ...value, | ||
| 216 | + requestId: INVALID_REQUEST_ID, | ||
| 217 | + favoriteId: INVALID_REQUEST_ID, | ||
| 218 | + }); | ||
| 219 | + } catch { | ||
| 220 | + return false; | ||
| 221 | + } | ||
| 222 | +} | ||
| 223 | + | ||
| 224 | +function isPublicDepartureError(value: unknown): value is PublicDepartureError { | ||
| 225 | + if (!record(value)) return false; | ||
| 226 | + | ||
| 227 | + try { | ||
| 228 | + return Object.keys(value).every((key) => PUBLIC_ERROR_KEYS.includes(key)) | ||
| 229 | + && "schemaVersion" in value | ||
| 230 | + && "code" in value | ||
| 231 | + && "occurredAt" in value | ||
| 232 | + && isErrorResult({ ...value, requestId: INVALID_REQUEST_ID }); | ||
| 233 | + } catch { | ||
| 234 | + return false; | ||
| 235 | + } | ||
| 236 | +} | ||
| 237 | + | ||
| 238 | +function defaultAuthorizationParser(value: unknown): string { | ||
| 239 | + if ( | ||
| 240 | + typeof value !== "string" | ||
| 241 | + || !value.startsWith(BEARER_PREFIX) | ||
| 242 | + || value.length === BEARER_PREFIX.length | ||
| 243 | + || value.charCodeAt(BEARER_PREFIX.length) === 0x20 | ||
| 244 | + ) { | ||
| 245 | + throw new TypeError("Invalid bearer authorization"); | ||
| 246 | + } | ||
| 247 | + return value.slice(BEARER_PREFIX.length); | ||
| 248 | +} | ||
| 249 | + | ||
| 250 | +function authorizationMissing(value: unknown): boolean { | ||
| 251 | + return value === undefined || value === null || value === ""; | ||
| 252 | +} | ||
| 253 | + | ||
| 254 | +function parseAuthorization( | ||
| 255 | + value: unknown, | ||
| 256 | + parser: DepartureAuthorizationParser | undefined, | ||
| 257 | +): string { | ||
| 258 | + const apiKey = (parser ?? defaultAuthorizationParser)(value); | ||
| 259 | + if (!isPersonalApiKey(apiKey)) throw new TypeError("Invalid bearer authorization"); | ||
| 260 | + return apiKey; | ||
| 261 | +} | ||
| 262 | + | ||
| 263 | +function dependencyClock(dependencies: DepartureHandlerDependencies): DepartureClock | undefined { | ||
| 264 | + return dependencies.clock ?? dependencies.nowMilliseconds; | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +function nowMilliseconds(clock: DepartureClock | undefined): number { | ||
| 268 | + try { | ||
| 269 | + const value = clock?.() ?? Date.now(); | ||
| 270 | + return Number.isFinite(value) && value >= 0 ? value : Date.now(); | ||
| 271 | + } catch { | ||
| 272 | + return Date.now(); | ||
| 273 | + } | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +function occurredAt(milliseconds: number): number { | ||
| 277 | + return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | ||
| 278 | +} | ||
| 279 | + | ||
| 280 | +function stableError( | ||
| 281 | + binding: DepartureBinding, | ||
| 282 | + code: ErrorCode, | ||
| 283 | + milliseconds: number, | ||
| 284 | +): ErrorResult { | ||
| 285 | + const result: ErrorResult = { | ||
| 286 | + schemaVersion: SCHEMA_VERSION, | ||
| 287 | + requestId: binding.requestId, | ||
| 288 | + code, | ||
| 289 | + occurredAt: occurredAt(milliseconds), | ||
| 290 | + }; | ||
| 291 | + if (binding.favoriteId !== undefined) result.favoriteId = binding.favoriteId; | ||
| 292 | + return result; | ||
| 293 | +} | ||
| 294 | + | ||
| 295 | +function publicError(code: ErrorCode, milliseconds: number): PublicDepartureError { | ||
| 296 | + return { | ||
| 297 | + schemaVersion: SCHEMA_VERSION, | ||
| 298 | + code, | ||
| 299 | + occurredAt: occurredAt(milliseconds), | ||
| 300 | + }; | ||
| 301 | +} | ||
| 302 | + | ||
| 303 | +function copyDeparture(value: Departure): Departure { | ||
| 304 | + const copy: Departure = { | ||
| 305 | + expectedAt: value.expectedAt, | ||
| 306 | + minutes: value.minutes, | ||
| 307 | + status: value.status, | ||
| 308 | + }; | ||
| 309 | + if (value.aimedAt !== undefined) copy.aimedAt = value.aimedAt; | ||
| 310 | + if (value.nextIntervalMinutes !== undefined) copy.nextIntervalMinutes = value.nextIntervalMinutes; | ||
| 311 | + return copy; | ||
| 312 | +} | ||
| 313 | + | ||
| 314 | +function copyPublicDeparture(value: PublicDepartureData): PublicDepartureData { | ||
| 315 | + const copy: PublicDepartureData = { | ||
| 316 | + schemaVersion: SCHEMA_VERSION, | ||
| 317 | + fetchedAt: value.fetchedAt, | ||
| 318 | + freshness: value.freshness, | ||
| 319 | + departures: value.departures.map(copyDeparture), | ||
| 320 | + }; | ||
| 321 | + if (value.sourceUpdatedAt !== undefined) copy.sourceUpdatedAt = value.sourceUpdatedAt; | ||
| 322 | + return copy; | ||
| 323 | +} | ||
| 324 | + | ||
| 325 | +function bindDeparture( | ||
| 326 | + data: PublicDepartureData, | ||
| 327 | + request: DepartureRequestBody, | ||
| 328 | +): DepartureResult { | ||
| 329 | + const result: DepartureResult = { | ||
| 330 | + schemaVersion: SCHEMA_VERSION, | ||
| 331 | + requestId: request.requestId, | ||
| 332 | + favoriteId: request.favoriteId, | ||
| 333 | + fetchedAt: data.fetchedAt, | ||
| 334 | + freshness: data.freshness, | ||
| 335 | + departures: data.departures.map(copyDeparture), | ||
| 336 | + }; | ||
| 337 | + if (data.sourceUpdatedAt !== undefined) result.sourceUpdatedAt = data.sourceUpdatedAt; | ||
| 338 | + return result; | ||
| 339 | +} | ||
| 340 | + | ||
| 341 | +function bindPublicError( | ||
| 342 | + error: PublicDepartureError, | ||
| 343 | + request: DepartureRequestBody, | ||
| 344 | +): ErrorResult { | ||
| 345 | + const result: ErrorResult = { | ||
| 346 | + schemaVersion: SCHEMA_VERSION, | ||
| 347 | + requestId: request.requestId, | ||
| 348 | + favoriteId: request.favoriteId, | ||
| 349 | + code: error.code, | ||
| 350 | + occurredAt: error.occurredAt, | ||
| 351 | + }; | ||
| 352 | + if (error.retryAfterSeconds !== undefined) result.retryAfterSeconds = error.retryAfterSeconds; | ||
| 353 | + return result; | ||
| 354 | +} | ||
| 355 | + | ||
| 356 | +function metric( | ||
| 357 | + metrics: DepartureMetrics | undefined, | ||
| 358 | + name: DepartureMetricName, | ||
| 359 | + value: number, | ||
| 360 | +): void { | ||
| 361 | + if (!Number.isFinite(value) || value < 0) return; | ||
| 362 | + try { | ||
| 363 | + metrics?.record(name, value); | ||
| 364 | + } catch { | ||
| 365 | + // Instrumentation must never affect request settlement. | ||
| 366 | + } | ||
| 367 | +} | ||
| 368 | + | ||
| 369 | +function normalizedBytes(data: PublicDepartureData): number { | ||
| 370 | + return utf8Bytes(JSON.stringify(data)); | ||
| 371 | +} | ||
| 372 | + | ||
| 373 | +function cachedDeparture( | ||
| 374 | + state: DepartureRuntimeState, | ||
| 375 | + serviceId: string, | ||
| 376 | + milliseconds: number, | ||
| 377 | +): PublicDepartureData | undefined { | ||
| 378 | + const cached = state.cache.get(serviceId); | ||
| 379 | + if (cached === undefined) return undefined; | ||
| 380 | + | ||
| 381 | + const age = milliseconds - cached.storedAtMilliseconds; | ||
| 382 | + if ( | ||
| 383 | + !Number.isFinite(cached.storedAtMilliseconds) | ||
| 384 | + || age < 0 | ||
| 385 | + || age >= DEPARTURE_CACHE_FRESH_MS | ||
| 386 | + || !isPublicDepartureData(cached.data) | ||
| 387 | + ) { | ||
| 388 | + state.cache.delete(serviceId); | ||
| 389 | + return undefined; | ||
| 390 | + } | ||
| 391 | + return cached.data; | ||
| 392 | +} | ||
| 393 | + | ||
| 394 | +function settleEvent( | ||
| 395 | + event: PendingDepartureEvent, | ||
| 396 | + result: DepartureHandlerResponse, | ||
| 397 | +): void { | ||
| 398 | + if (event.settled) return; | ||
| 399 | + event.settled = true; | ||
| 400 | + event.apiKey = null; | ||
| 401 | + event.resolver = null; | ||
| 402 | + event.clock = undefined; | ||
| 403 | + event.metrics = undefined; | ||
| 404 | + const complete = event.complete; | ||
| 405 | + event.complete = null; | ||
| 406 | + complete?.(result); | ||
| 407 | +} | ||
| 408 | + | ||
| 409 | +function removeFlight(state: DepartureRuntimeState, flight: DepartureFlight): void { | ||
| 410 | + if (state.flights.get(flight.serviceId) === flight) { | ||
| 411 | + state.flights.delete(flight.serviceId); | ||
| 412 | + } | ||
| 413 | +} | ||
| 414 | + | ||
| 415 | +function allFlightEvents(flight: DepartureFlight): PendingDepartureEvent[] { | ||
| 416 | + const events = flight.active === null | ||
| 417 | + ? flight.waiters | ||
| 418 | + : [flight.active, ...flight.waiters]; | ||
| 419 | + flight.active = null; | ||
| 420 | + flight.waiters = []; | ||
| 421 | + return events; | ||
| 422 | +} | ||
| 423 | + | ||
| 424 | +function settleSharedError( | ||
| 425 | + state: DepartureRuntimeState, | ||
| 426 | + flight: DepartureFlight, | ||
| 427 | + error: PublicDepartureError, | ||
| 428 | +): void { | ||
| 429 | + const events = allFlightEvents(flight); | ||
| 430 | + removeFlight(state, flight); | ||
| 431 | + for (const event of events) settleEvent(event, bindPublicError(error, event.request)); | ||
| 432 | +} | ||
| 433 | + | ||
| 434 | +function settleSuccess( | ||
| 435 | + state: DepartureRuntimeState, | ||
| 436 | + flight: DepartureFlight, | ||
| 437 | + data: PublicDepartureData, | ||
| 438 | + storedAtMilliseconds: number, | ||
| 439 | +): void { | ||
| 440 | + const cached = copyPublicDeparture(data); | ||
| 441 | + state.cache.set(flight.serviceId, { storedAtMilliseconds, data: cached }); | ||
| 442 | + const events = allFlightEvents(flight); | ||
| 443 | + removeFlight(state, flight); | ||
| 444 | + const bytes = normalizedBytes(cached); | ||
| 445 | + for (const event of events) { | ||
| 446 | + metric(event.metrics, DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES, bytes); | ||
| 447 | + settleEvent(event, bindDeparture(cached, event.request)); | ||
| 448 | + } | ||
| 449 | +} | ||
| 450 | + | ||
| 451 | + | ||
| 452 | +function settleCredentialFailure( | ||
| 453 | + state: DepartureRuntimeState, | ||
| 454 | + flight: DepartureFlight, | ||
| 455 | + active: PendingDepartureEvent, | ||
| 456 | + activeKey: string, | ||
| 457 | + error: PublicDepartureError, | ||
| 458 | +): void { | ||
| 459 | + const sameCredential: PendingDepartureEvent[] = [active]; | ||
| 460 | + const remaining: PendingDepartureEvent[] = []; | ||
| 461 | + | ||
| 462 | + for (const waiter of flight.waiters) { | ||
| 463 | + if (waiter.apiKey === activeKey) sameCredential.push(waiter); | ||
| 464 | + else remaining.push(waiter); | ||
| 465 | + } | ||
| 466 | + | ||
| 467 | + flight.active = null; | ||
| 468 | + flight.waiters = remaining; | ||
| 469 | + for (const event of sameCredential) { | ||
| 470 | + settleEvent(event, bindPublicError(error, event.request)); | ||
| 471 | + } | ||
| 472 | + | ||
| 473 | + const nextIndex = remaining.findIndex((event) => | ||
| 474 | + !event.promoted && event.apiKey !== null && event.apiKey !== activeKey | ||
| 475 | + ); | ||
| 476 | + if (nextIndex < 0) { | ||
| 477 | + const unsettled = allFlightEvents(flight); | ||
| 478 | + removeFlight(state, flight); | ||
| 479 | + const fallback = publicError("INVALID_RESPONSE", nowMilliseconds(active.clock)); | ||
| 480 | + for (const event of unsettled) settleEvent(event, bindPublicError(fallback, event.request)); | ||
| 481 | + return; | ||
| 482 | + } | ||
| 483 | + | ||
| 484 | + const [next] = remaining.splice(nextIndex, 1); | ||
| 485 | + if (next === undefined) return; | ||
| 486 | + next.promoted = true; | ||
| 487 | + flight.active = next; | ||
| 488 | + runPromotedEvent(state, flight, next); | ||
| 489 | +} | ||
| 490 | + | ||
| 491 | +function runPromotedEvent( | ||
| 492 | + state: DepartureRuntimeState, | ||
| 493 | + flight: DepartureFlight, | ||
| 494 | + active: PendingDepartureEvent, | ||
| 495 | +): void { | ||
| 496 | + void (async () => { | ||
| 497 | + let activeKey = active.apiKey; | ||
| 498 | + const resolver = active.resolver; | ||
| 499 | + active.resolver = null; | ||
| 500 | + | ||
| 501 | + if (activeKey === null || resolver === null) { | ||
| 502 | + settleSharedError( | ||
| 503 | + state, | ||
| 504 | + flight, | ||
| 505 | + publicError("INVALID_RESPONSE", nowMilliseconds(active.clock)), | ||
| 506 | + ); | ||
| 507 | + return; | ||
| 508 | + } | ||
| 509 | + | ||
| 510 | + const resolverRequest: DepartureResolverRequest = { | ||
| 511 | + serviceId: flight.serviceId, | ||
| 512 | + apiKey: activeKey, | ||
| 513 | + }; | ||
| 514 | + | ||
| 515 | + let resolved: unknown; | ||
| 516 | + let failed = false; | ||
| 517 | + try { | ||
| 518 | + resolved = await resolver(resolverRequest); | ||
| 519 | + } catch { | ||
| 520 | + failed = true; | ||
| 521 | + } | ||
| 522 | + | ||
| 523 | + const completedAt = nowMilliseconds(active.clock); | ||
| 524 | + | ||
| 525 | + try { | ||
| 526 | + if (failed) { | ||
| 527 | + settleSharedError(state, flight, publicError("SOURCE_UNAVAILABLE", completedAt)); | ||
| 528 | + return; | ||
| 529 | + } | ||
| 530 | + | ||
| 531 | + if (isPublicDepartureData(resolved)) { | ||
| 532 | + settleSuccess(state, flight, resolved, completedAt); | ||
| 533 | + return; | ||
| 534 | + } | ||
| 535 | + | ||
| 536 | + if (isPublicDepartureError(resolved)) { | ||
| 537 | + if (resolved.code === "API_KEY_INVALID" || resolved.code === "RATE_LIMITED") { | ||
| 538 | + settleCredentialFailure(state, flight, active, activeKey, resolved); | ||
| 539 | + } else { | ||
| 540 | + settleSharedError(state, flight, resolved); | ||
| 541 | + } | ||
| 542 | + return; | ||
| 543 | + } | ||
| 544 | + | ||
| 545 | + settleSharedError(state, flight, publicError("INVALID_RESPONSE", completedAt)); | ||
| 546 | + } catch { | ||
| 547 | + if (!active.settled && state.flights.get(flight.serviceId) === flight) { | ||
| 548 | + settleSharedError(state, flight, publicError("SOURCE_UNAVAILABLE", completedAt)); | ||
| 549 | + } | ||
| 550 | + } finally { | ||
| 551 | + activeKey = null; | ||
| 552 | + } | ||
| 553 | + })(); | ||
| 554 | +} | ||
| 555 | + | ||
| 556 | +export async function handleDepartureRequest( | ||
| 557 | + request: DepartureHandlerRequest, | ||
| 558 | + dependencies: DepartureHandlerDependencies, | ||
| 559 | +): Promise<DepartureHandlerResponse> { | ||
| 560 | + let authorization: unknown; | ||
| 561 | + let bodyValue: unknown; | ||
| 562 | + try { | ||
| 563 | + authorization = request.authorization; | ||
| 564 | + bodyValue = request.body; | ||
| 565 | + } catch { | ||
| 566 | + authorization = undefined; | ||
| 567 | + bodyValue = undefined; | ||
| 568 | + } | ||
| 569 | + | ||
| 570 | + const binding = departureBinding(bodyValue); | ||
| 571 | + let apiKey: string | null = null; | ||
| 572 | + try { | ||
| 573 | + apiKey = parseAuthorization(authorization, dependencies.authorizationParser); | ||
| 574 | + } catch { | ||
| 575 | + const code: ErrorCode = authorizationMissing(authorization) | ||
| 576 | + ? "API_KEY_REQUIRED" | ||
| 577 | + : "API_KEY_INVALID"; | ||
| 578 | + return stableError(binding, code, nowMilliseconds(dependencyClock(dependencies))); | ||
| 579 | + } | ||
| 580 | + | ||
| 581 | + const body = parseDepartureRequestBody(bodyValue); | ||
| 582 | + if (body === undefined) { | ||
| 583 | + apiKey = null; | ||
| 584 | + return stableError( | ||
| 585 | + binding, | ||
| 586 | + "INVALID_SERVICE", | ||
| 587 | + nowMilliseconds(dependencyClock(dependencies)), | ||
| 588 | + ); | ||
| 589 | + } | ||
| 590 | + | ||
| 591 | + const state = dependencies.state ?? DEFAULT_RUNTIME_STATE; | ||
| 592 | + const clock = dependencyClock(dependencies); | ||
| 593 | + const currentMilliseconds = nowMilliseconds(clock); | ||
| 594 | + const cached = cachedDeparture(state, body.serviceId, currentMilliseconds); | ||
| 595 | + if (cached !== undefined) { | ||
| 596 | + apiKey = null; | ||
| 597 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.CACHE_HIT, 1); | ||
| 598 | + metric( | ||
| 599 | + dependencies.metrics, | ||
| 600 | + DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES, | ||
| 601 | + normalizedBytes(cached), | ||
| 602 | + ); | ||
| 603 | + return bindDeparture(cached, body); | ||
| 604 | + } | ||
| 605 | + | ||
| 606 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.CACHE_MISS, 1); | ||
| 607 | + | ||
| 608 | + let complete: ((result: DepartureHandlerResponse) => void) | null = null; | ||
| 609 | + const result = new Promise<DepartureHandlerResponse>((resolve) => { | ||
| 610 | + complete = resolve; | ||
| 611 | + }); | ||
| 612 | + const event: PendingDepartureEvent = { | ||
| 613 | + request: body, | ||
| 614 | + apiKey, | ||
| 615 | + resolver: dependencies.resolve, | ||
| 616 | + clock, | ||
| 617 | + metrics: dependencies.metrics, | ||
| 618 | + complete, | ||
| 619 | + promoted: false, | ||
| 620 | + settled: false, | ||
| 621 | + }; | ||
| 622 | + apiKey = null; | ||
| 623 | + | ||
| 624 | + const existing = state.flights.get(body.serviceId); | ||
| 625 | + if (existing !== undefined) { | ||
| 626 | + existing.waiters.push(event); | ||
| 627 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.COALESCED_REQUEST, 1); | ||
| 628 | + } else { | ||
| 629 | + const flight: DepartureFlight = { | ||
| 630 | + serviceId: body.serviceId, | ||
| 631 | + active: event, | ||
| 632 | + waiters: [], | ||
| 633 | + }; | ||
| 634 | + event.promoted = true; | ||
| 635 | + state.flights.set(body.serviceId, flight); | ||
| 636 | + runPromotedEvent(state, flight, event); | ||
| 637 | + } | ||
| 638 | + | ||
| 639 | + return result; | ||
| 640 | +} | ||
| 641 | + | ||
| 642 | +export function departureHttpStatus(response: DepartureHandlerResponse): DepartureHttpStatus { | ||
| 643 | + if (isDepartureResult(response)) return 200; | ||
| 644 | + switch (response.code) { | ||
| 645 | + case "API_KEY_REQUIRED": | ||
| 646 | + case "API_KEY_INVALID": | ||
| 647 | + return 401; | ||
| 648 | + case "INVALID_SERVICE": | ||
| 649 | + return 400; | ||
| 650 | + case "RATE_LIMITED": | ||
| 651 | + return 429; | ||
| 652 | + case "SOURCE_UNAVAILABLE": | ||
| 653 | + return 503; | ||
| 654 | + case "INVALID_RESPONSE": | ||
| 655 | + return 502; | ||
| 656 | + } | ||
| 657 | +} | ||
| 658 | + | ||
| 659 | +export async function handleDepartureHttpRequest( | ||
| 660 | + request: DepartureHttpRequest, | ||
| 661 | + dependencies: DepartureHandlerDependencies, | ||
| 662 | +): Promise<DepartureHttpResponse> { | ||
| 663 | + let method: string; | ||
| 664 | + let path: string; | ||
| 665 | + let authorization: unknown; | ||
| 666 | + let body: unknown; | ||
| 667 | + try { | ||
| 668 | + method = request.method; | ||
| 669 | + path = request.path; | ||
| 670 | + authorization = request.authorization; | ||
| 671 | + body = request.body; | ||
| 672 | + } catch { | ||
| 673 | + method = ""; | ||
| 674 | + path = ""; | ||
| 675 | + authorization = undefined; | ||
| 676 | + body = undefined; | ||
| 677 | + } | ||
| 678 | + | ||
| 679 | + const binding = departureBinding(body); | ||
| 680 | + if (path !== DEPARTURE_ROUTE) { | ||
| 681 | + return { | ||
| 682 | + status: 404, | ||
| 683 | + body: stableError(binding, "INVALID_SERVICE", nowMilliseconds(dependencyClock(dependencies))), | ||
| 684 | + }; | ||
| 685 | + } | ||
| 686 | + if (method !== "POST") { | ||
| 687 | + return { | ||
| 688 | + status: 405, | ||
| 689 | + body: stableError(binding, "INVALID_SERVICE", nowMilliseconds(dependencyClock(dependencies))), | ||
| 690 | + }; | ||
| 691 | + } | ||
| 692 | + | ||
| 693 | + const response = await handleDepartureRequest({ authorization, body }, dependencies); | ||
| 694 | + return { status: departureHttpStatus(response), body: response }; | ||
| 695 | +} | ||
modified
packages/backend/src/index.ts +34 -317 | @@ -1,16 +1,7 @@ | ||
| 1 | 1 | import { |
| 2 | - CACHE_FRESH_SECONDS, | |
| 3 | 2 | LIMITS, |
| 4 | 3 | PRIM_ORIGIN, |
| 5 | - SCHEMA_VERSION, | |
| 6 | - isDepartureResult, | |
| 7 | - isErrorResult, | |
| 8 | 4 | isPersonalApiKey, |
| 9 | - utf8Bytes, | |
| 10 | - type Departure, | |
| 11 | - type DepartureResult, | |
| 12 | - type ErrorCode, | |
| 13 | - type ErrorResult, | |
| 14 | 5 | } from "../../contracts/src/index.ts"; |
| 15 | 6 | |
| 16 | 7 | export { |
| @@ -22,6 +13,11 @@ export { | ||
| 22 | 13 | type CatalogServiceResolution, |
| 23 | 14 | } from "./catalog.ts"; |
| 24 | 15 | |
| 16 | +export * from "./departures.ts"; | |
| 17 | +export * from "./prim-departures.ts"; | |
| 18 | +export * from "./departure-service.ts"; | |
| 19 | +export * from "./departure-endpoint.ts"; | |
| 20 | + | |
| 25 | 21 | export const REDACTED_SECRET = "[REDACTED]" as const; |
| 26 | 22 | |
| 27 | 23 | export const BACKEND_ERROR_CODE = { |
| @@ -149,7 +145,11 @@ export function createRedactingLogger( | ||
| 149 | 145 | |
| 150 | 146 | export interface PrimFetchInit { |
| 151 | 147 | readonly method: "GET"; |
| 152 | - readonly headers: Readonly<{ apikey: string }>; | |
| 148 | + readonly headers: Readonly<{ | |
| 149 | + apikey: string; | |
| 150 | + accept: "application/json"; | |
| 151 | + "accept-encoding": "gzip"; | |
| 152 | + }>; | |
| 153 | 153 | readonly redirect: "manual"; |
| 154 | 154 | readonly credentials: "omit"; |
| 155 | 155 | readonly cache: "no-store"; |
| @@ -160,6 +160,9 @@ export interface PrimFetchResponse { | ||
| 160 | 160 | readonly status: number; |
| 161 | 161 | readonly redirected?: boolean; |
| 162 | 162 | readonly type?: string; |
| 163 | + readonly headers?: { | |
| 164 | + get(name: string): string | null; | |
| 165 | + }; | |
| 163 | 166 | readonly body: ReadableStream<Uint8Array> | null; |
| 164 | 167 | } |
| 165 | 168 | |
| @@ -184,6 +187,7 @@ export interface PrimRelayRequest { | ||
| 184 | 187 | export interface PrimRelayResponse { |
| 185 | 188 | readonly status: number; |
| 186 | 189 | readonly bytes: Uint8Array; |
| 190 | + readonly retryAfter?: string; | |
| 187 | 191 | } |
| 188 | 192 | |
| 189 | 193 | const SYSTEM_TIMER: RelayTimer = { |
| @@ -340,7 +344,11 @@ async function performRelay( | ||
| 340 | 344 | ): Promise<PrimRelayResponse> { |
| 341 | 345 | const response = await fetch(target, { |
| 342 | 346 | method: "GET", |
| 343 | - headers: Object.freeze({ apikey: apiKey }), | |
| 347 | + headers: Object.freeze({ | |
| 348 | + apikey: apiKey, | |
| 349 | + accept: "application/json", | |
| 350 | + "accept-encoding": "gzip", | |
| 351 | + }), | |
| 344 | 352 | redirect: "manual", |
| 345 | 353 | credentials: "omit", |
| 346 | 354 | cache: "no-store", |
| @@ -357,9 +365,24 @@ async function performRelay( | ||
| 357 | 365 | throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE); |
| 358 | 366 | } |
| 359 | 367 | |
| 368 | + let retryAfter: string | undefined; | |
| 369 | + if (response.headers !== undefined) { | |
| 370 | + let value: string | null; | |
| 371 | + try { | |
| 372 | + value = response.headers.get("retry-after"); | |
| 373 | + } catch { | |
| 374 | + throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE); | |
| 375 | + } | |
| 376 | + if (value !== null && typeof value !== "string") { | |
| 377 | + throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE); | |
| 378 | + } | |
| 379 | + if (value !== null) retryAfter = value; | |
| 380 | + } | |
| 381 | + | |
| 360 | 382 | return { |
| 361 | 383 | status: response.status, |
| 362 | 384 | bytes: await readBoundedBody(response.body, controller), |
| 385 | + ...(retryAfter === undefined ? {} : { retryAfter }), | |
| 363 | 386 | }; |
| 364 | 387 | } |
| 365 | 388 | |
| @@ -409,309 +432,3 @@ export async function relayPrimRequest( | ||
| 409 | 432 | } |
| 410 | 433 | } |
| 411 | 434 | |
| 412 | -export interface DepartureRequestBody { | |
| 413 | - readonly schemaVersion: typeof SCHEMA_VERSION; | |
| 414 | - readonly requestId: string; | |
| 415 | - readonly favoriteId: string; | |
| 416 | - readonly serviceId: string; | |
| 417 | -} | |
| 418 | - | |
| 419 | -export interface DepartureHandlerRequest { | |
| 420 | - readonly authorization: unknown; | |
| 421 | - readonly body: unknown; | |
| 422 | -} | |
| 423 | - | |
| 424 | -export type PublicDepartureData = Omit<DepartureResult, "requestId" | "favoriteId">; | |
| 425 | -export type PublicDepartureError = Omit<ErrorResult, "requestId" | "favoriteId">; | |
| 426 | -export type DepartureResolverResult = PublicDepartureData | PublicDepartureError; | |
| 427 | - | |
| 428 | -export interface DepartureResolverRequest { | |
| 429 | - readonly serviceId: string; | |
| 430 | - readonly apiKey: string; | |
| 431 | -} | |
| 432 | - | |
| 433 | -export type DepartureResolver = ( | |
| 434 | - request: DepartureResolverRequest, | |
| 435 | -) => Promise<DepartureResolverResult>; | |
| 436 | - | |
| 437 | -export interface DepartureHandlerDependencies { | |
| 438 | - readonly resolve: DepartureResolver; | |
| 439 | - readonly nowMilliseconds?: () => number; | |
| 440 | -} | |
| 441 | - | |
| 442 | -export type DepartureHandlerResponse = DepartureResult | ErrorResult; | |
| 443 | - | |
| 444 | -interface DepartureBinding { | |
| 445 | - readonly requestId: string; | |
| 446 | - readonly favoriteId?: string; | |
| 447 | -} | |
| 448 | - | |
| 449 | -interface CachedDeparture { | |
| 450 | - readonly storedAtMilliseconds: number; | |
| 451 | - readonly data: PublicDepartureData; | |
| 452 | -} | |
| 453 | - | |
| 454 | -const DEPARTURE_REQUEST_KEYS = ["schemaVersion", "requestId", "favoriteId", "serviceId"] as const; | |
| 455 | -const PUBLIC_DEPARTURE_KEYS = [ | |
| 456 | - "schemaVersion", | |
| 457 | - "fetchedAt", | |
| 458 | - "sourceUpdatedAt", | |
| 459 | - "freshness", | |
| 460 | - "departures", | |
| 461 | -] as const; | |
| 462 | -const PUBLIC_ERROR_KEYS = [ | |
| 463 | - "schemaVersion", | |
| 464 | - "code", | |
| 465 | - "occurredAt", | |
| 466 | - "retryAfterSeconds", | |
| 467 | -] as const; | |
| 468 | -const INVALID_REQUEST_ID = "invalid-request"; | |
| 469 | -const UINT32_MAX = 0xffff_ffff; | |
| 470 | -const DEPARTURE_CACHE_FRESH_MS = CACHE_FRESH_SECONDS * 1_000; | |
| 471 | -const DEPARTURE_CACHE = new Map<string, CachedDeparture>(); | |
| 472 | - | |
| 473 | -function record(value: unknown): value is Record<string, unknown> { | |
| 474 | - return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 475 | -} | |
| 476 | - | |
| 477 | -function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean { | |
| 478 | - return Object.keys(value).every((key) => allowed.includes(key)); | |
| 479 | -} | |
| 480 | - | |
| 481 | -function boundedId(value: unknown): value is string { | |
| 482 | - return typeof value === "string" | |
| 483 | - && utf8Bytes(value) >= 1 | |
| 484 | - && utf8Bytes(value) <= LIMITS.idUtf8Bytes; | |
| 485 | -} | |
| 486 | - | |
| 487 | -function departureBinding(value: unknown): DepartureBinding { | |
| 488 | - if (!record(value)) return { requestId: INVALID_REQUEST_ID }; | |
| 489 | - | |
| 490 | - try { | |
| 491 | - const requestId = boundedId(value.requestId) ? value.requestId : INVALID_REQUEST_ID; | |
| 492 | - const favoriteId = boundedId(value.favoriteId) ? value.favoriteId : undefined; | |
| 493 | - return favoriteId === undefined ? { requestId } : { requestId, favoriteId }; | |
| 494 | - } catch { | |
| 495 | - return { requestId: INVALID_REQUEST_ID }; | |
| 496 | - } | |
| 497 | -} | |
| 498 | - | |
| 499 | -function isDepartureRequestBody(value: unknown): value is DepartureRequestBody { | |
| 500 | - if (!record(value)) return false; | |
| 501 | - | |
| 502 | - try { | |
| 503 | - return hasOnlyKeys(value, DEPARTURE_REQUEST_KEYS) | |
| 504 | - && value.schemaVersion === SCHEMA_VERSION | |
| 505 | - && boundedId(value.requestId) | |
| 506 | - && boundedId(value.favoriteId) | |
| 507 | - && boundedId(value.serviceId); | |
| 508 | - } catch { | |
| 509 | - return false; | |
| 510 | - } | |
| 511 | -} | |
| 512 | - | |
| 513 | -function isPublicDepartureData(value: unknown): value is PublicDepartureData { | |
| 514 | - if (!record(value)) return false; | |
| 515 | - | |
| 516 | - try { | |
| 517 | - return hasOnlyKeys(value, PUBLIC_DEPARTURE_KEYS) | |
| 518 | - && isDepartureResult({ | |
| 519 | - ...value, | |
| 520 | - requestId: INVALID_REQUEST_ID, | |
| 521 | - favoriteId: INVALID_REQUEST_ID, | |
| 522 | - }); | |
| 523 | - } catch { | |
| 524 | - return false; | |
| 525 | - } | |
| 526 | -} | |
| 527 | - | |
| 528 | -function isPublicDepartureError(value: unknown): value is PublicDepartureError { | |
| 529 | - if (!record(value)) return false; | |
| 530 | - | |
| 531 | - try { | |
| 532 | - return hasOnlyKeys(value, PUBLIC_ERROR_KEYS) | |
| 533 | - && isErrorResult({ ...value, requestId: INVALID_REQUEST_ID }); | |
| 534 | - } catch { | |
| 535 | - return false; | |
| 536 | - } | |
| 537 | -} | |
| 538 | - | |
| 539 | -function nowMilliseconds(dependencies: DepartureHandlerDependencies): number { | |
| 540 | - const fallback = Date.now(); | |
| 541 | - try { | |
| 542 | - const value = dependencies.nowMilliseconds?.() ?? fallback; | |
| 543 | - return Number.isFinite(value) && value >= 0 ? value : fallback; | |
| 544 | - } catch { | |
| 545 | - return fallback; | |
| 546 | - } | |
| 547 | -} | |
| 548 | - | |
| 549 | -function occurredAt(milliseconds: number): number { | |
| 550 | - return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | |
| 551 | -} | |
| 552 | - | |
| 553 | -function stableError( | |
| 554 | - binding: DepartureBinding, | |
| 555 | - code: ErrorCode, | |
| 556 | - milliseconds: number, | |
| 557 | -): ErrorResult { | |
| 558 | - const result: ErrorResult = { | |
| 559 | - schemaVersion: SCHEMA_VERSION, | |
| 560 | - requestId: binding.requestId, | |
| 561 | - code, | |
| 562 | - occurredAt: occurredAt(milliseconds), | |
| 563 | - }; | |
| 564 | - if (binding.favoriteId !== undefined) result.favoriteId = binding.favoriteId; | |
| 565 | - return result; | |
| 566 | -} | |
| 567 | - | |
| 568 | -function copyDeparture(value: Departure): Departure { | |
| 569 | - const copy: Departure = { | |
| 570 | - expectedAt: value.expectedAt, | |
| 571 | - minutes: value.minutes, | |
| 572 | - status: value.status, | |
| 573 | - }; | |
| 574 | - if (value.aimedAt !== undefined) copy.aimedAt = value.aimedAt; | |
| 575 | - if (value.nextIntervalMinutes !== undefined) { | |
| 576 | - copy.nextIntervalMinutes = value.nextIntervalMinutes; | |
| 577 | - } | |
| 578 | - return copy; | |
| 579 | -} | |
| 580 | - | |
| 581 | -function normalizePublicDeparture(value: PublicDepartureData): PublicDepartureData { | |
| 582 | - const normalized: PublicDepartureData = { | |
| 583 | - schemaVersion: SCHEMA_VERSION, | |
| 584 | - fetchedAt: value.fetchedAt, | |
| 585 | - freshness: value.freshness, | |
| 586 | - departures: value.departures.map(copyDeparture), | |
| 587 | - }; | |
| 588 | - if (value.sourceUpdatedAt !== undefined) { | |
| 589 | - normalized.sourceUpdatedAt = value.sourceUpdatedAt; | |
| 590 | - } | |
| 591 | - return normalized; | |
| 592 | -} | |
| 593 | - | |
| 594 | -function bindDeparture( | |
| 595 | - data: PublicDepartureData, | |
| 596 | - request: DepartureRequestBody, | |
| 597 | -): DepartureResult { | |
| 598 | - const result: DepartureResult = { | |
| 599 | - schemaVersion: SCHEMA_VERSION, | |
| 600 | - requestId: request.requestId, | |
| 601 | - favoriteId: request.favoriteId, | |
| 602 | - fetchedAt: data.fetchedAt, | |
| 603 | - freshness: data.freshness, | |
| 604 | - departures: data.departures.map(copyDeparture), | |
| 605 | - }; | |
| 606 | - if (data.sourceUpdatedAt !== undefined) result.sourceUpdatedAt = data.sourceUpdatedAt; | |
| 607 | - return result; | |
| 608 | -} | |
| 609 | - | |
| 610 | -function bindPublicError( | |
| 611 | - error: PublicDepartureError, | |
| 612 | - request: DepartureRequestBody, | |
| 613 | -): ErrorResult { | |
| 614 | - const result: ErrorResult = { | |
| 615 | - schemaVersion: SCHEMA_VERSION, | |
| 616 | - requestId: request.requestId, | |
| 617 | - favoriteId: request.favoriteId, | |
| 618 | - code: error.code, | |
| 619 | - occurredAt: error.occurredAt, | |
| 620 | - }; | |
| 621 | - if (error.retryAfterSeconds !== undefined) { | |
| 622 | - result.retryAfterSeconds = error.retryAfterSeconds; | |
| 623 | - } | |
| 624 | - return result; | |
| 625 | -} | |
| 626 | - | |
| 627 | -function cachedDeparture( | |
| 628 | - serviceId: string, | |
| 629 | - milliseconds: number, | |
| 630 | -): PublicDepartureData | undefined { | |
| 631 | - const cached = DEPARTURE_CACHE.get(serviceId); | |
| 632 | - if (cached === undefined) return undefined; | |
| 633 | - | |
| 634 | - const age = milliseconds - cached.storedAtMilliseconds; | |
| 635 | - if (age < 0 || age >= DEPARTURE_CACHE_FRESH_MS) { | |
| 636 | - DEPARTURE_CACHE.delete(serviceId); | |
| 637 | - return undefined; | |
| 638 | - } | |
| 639 | - return cached.data; | |
| 640 | -} | |
| 641 | - | |
| 642 | -export async function handleDepartureRequest( | |
| 643 | - request: DepartureHandlerRequest, | |
| 644 | - dependencies: DepartureHandlerDependencies, | |
| 645 | -): Promise<DepartureHandlerResponse> { | |
| 646 | - let authorization: unknown; | |
| 647 | - let bodyValue: unknown; | |
| 648 | - try { | |
| 649 | - authorization = request.authorization; | |
| 650 | - bodyValue = request.body; | |
| 651 | - } catch { | |
| 652 | - authorization = undefined; | |
| 653 | - bodyValue = undefined; | |
| 654 | - } | |
| 655 | - | |
| 656 | - const binding = departureBinding(bodyValue); | |
| 657 | - let apiKey: string; | |
| 658 | - try { | |
| 659 | - apiKey = parseBearerAuthorization(authorization); | |
| 660 | - } catch (failure) { | |
| 661 | - const code: ErrorCode = failure instanceof BackendBoundaryError | |
| 662 | - && failure.code === BACKEND_ERROR_CODE.API_KEY_REQUIRED | |
| 663 | - ? "API_KEY_REQUIRED" | |
| 664 | - : "API_KEY_INVALID"; | |
| 665 | - return stableError(binding, code, nowMilliseconds(dependencies)); | |
| 666 | - } | |
| 667 | - | |
| 668 | - if (!isDepartureRequestBody(bodyValue)) { | |
| 669 | - apiKey = ""; | |
| 670 | - return stableError(binding, "INVALID_SERVICE", nowMilliseconds(dependencies)); | |
| 671 | - } | |
| 672 | - | |
| 673 | - const requestBody = bodyValue; | |
| 674 | - const currentMilliseconds = nowMilliseconds(dependencies); | |
| 675 | - const cached = cachedDeparture(requestBody.serviceId, currentMilliseconds); | |
| 676 | - if (cached !== undefined) { | |
| 677 | - apiKey = ""; | |
| 678 | - const result = bindDeparture(cached, requestBody); | |
| 679 | - return isDepartureResult(result) | |
| 680 | - ? result | |
| 681 | - : stableError(binding, "INVALID_RESPONSE", currentMilliseconds); | |
| 682 | - } | |
| 683 | - | |
| 684 | - let resolved: unknown; | |
| 685 | - try { | |
| 686 | - resolved = await dependencies.resolve({ | |
| 687 | - serviceId: requestBody.serviceId, | |
| 688 | - apiKey, | |
| 689 | - }); | |
| 690 | - } catch { | |
| 691 | - return stableError(binding, "SOURCE_UNAVAILABLE", nowMilliseconds(dependencies)); | |
| 692 | - } finally { | |
| 693 | - apiKey = ""; | |
| 694 | - } | |
| 695 | - | |
| 696 | - if (isPublicDepartureData(resolved)) { | |
| 697 | - const normalized = normalizePublicDeparture(resolved); | |
| 698 | - const storedAtMilliseconds = nowMilliseconds(dependencies); | |
| 699 | - DEPARTURE_CACHE.set(requestBody.serviceId, { | |
| 700 | - storedAtMilliseconds, | |
| 701 | - data: normalized, | |
| 702 | - }); | |
| 703 | - const result = bindDeparture(normalized, requestBody); | |
| 704 | - return isDepartureResult(result) | |
| 705 | - ? result | |
| 706 | - : stableError(binding, "INVALID_RESPONSE", storedAtMilliseconds); | |
| 707 | - } | |
| 708 | - | |
| 709 | - if (isPublicDepartureError(resolved)) { | |
| 710 | - const error = bindPublicError(resolved, requestBody); | |
| 711 | - return isErrorResult(error) | |
| 712 | - ? error | |
| 713 | - : stableError(binding, "INVALID_RESPONSE", nowMilliseconds(dependencies)); | |
| 714 | - } | |
| 715 | - | |
| 716 | - return stableError(binding, "INVALID_RESPONSE", nowMilliseconds(dependencies)); | |
| 717 | -} | |
| @@ -1,16 +1,7 @@ | |||
| 1 | import { | 1 | import { |
| 2 | - CACHE_FRESH_SECONDS, | ||
| 3 | LIMITS, | 2 | LIMITS, |
| 4 | PRIM_ORIGIN, | 3 | PRIM_ORIGIN, |
| 5 | - SCHEMA_VERSION, | ||
| 6 | - isDepartureResult, | ||
| 7 | - isErrorResult, | ||
| 8 | isPersonalApiKey, | 4 | isPersonalApiKey, |
| 9 | - utf8Bytes, | ||
| 10 | - type Departure, | ||
| 11 | - type DepartureResult, | ||
| 12 | - type ErrorCode, | ||
| 13 | - type ErrorResult, | ||
| 14 | } from "../../contracts/src/index.ts"; | 5 | } from "../../contracts/src/index.ts"; |
| 15 | 6 | ||
| 16 | export { | 7 | export { |
| @@ -22,6 +13,11 @@ export { | |||
| 22 | type CatalogServiceResolution, | 13 | type CatalogServiceResolution, |
| 23 | } from "./catalog.ts"; | 14 | } from "./catalog.ts"; |
| 24 | 15 | ||
| 16 | +export * from "./departures.ts"; | ||
| 17 | +export * from "./prim-departures.ts"; | ||
| 18 | +export * from "./departure-service.ts"; | ||
| 19 | +export * from "./departure-endpoint.ts"; | ||
| 20 | + | ||
| 25 | export const REDACTED_SECRET = "[REDACTED]" as const; | 21 | export const REDACTED_SECRET = "[REDACTED]" as const; |
| 26 | 22 | ||
| 27 | export const BACKEND_ERROR_CODE = { | 23 | export const BACKEND_ERROR_CODE = { |
| @@ -149,7 +145,11 @@ export function createRedactingLogger( | |||
| 149 | 145 | ||
| 150 | export interface PrimFetchInit { | 146 | export interface PrimFetchInit { |
| 151 | readonly method: "GET"; | 147 | readonly method: "GET"; |
| 152 | - readonly headers: Readonly<{ apikey: string }>; | 148 | + readonly headers: Readonly<{ |
| 149 | + apikey: string; | ||
| 150 | + accept: "application/json"; | ||
| 151 | + "accept-encoding": "gzip"; | ||
| 152 | + }>; | ||
| 153 | readonly redirect: "manual"; | 153 | readonly redirect: "manual"; |
| 154 | readonly credentials: "omit"; | 154 | readonly credentials: "omit"; |
| 155 | readonly cache: "no-store"; | 155 | readonly cache: "no-store"; |
| @@ -160,6 +160,9 @@ export interface PrimFetchResponse { | |||
| 160 | readonly status: number; | 160 | readonly status: number; |
| 161 | readonly redirected?: boolean; | 161 | readonly redirected?: boolean; |
| 162 | readonly type?: string; | 162 | readonly type?: string; |
| 163 | + readonly headers?: { | ||
| 164 | + get(name: string): string | null; | ||
| 165 | + }; | ||
| 163 | readonly body: ReadableStream<Uint8Array> | null; | 166 | readonly body: ReadableStream<Uint8Array> | null; |
| 164 | } | 167 | } |
| 165 | 168 | ||
| @@ -184,6 +187,7 @@ export interface PrimRelayRequest { | |||
| 184 | export interface PrimRelayResponse { | 187 | export interface PrimRelayResponse { |
| 185 | readonly status: number; | 188 | readonly status: number; |
| 186 | readonly bytes: Uint8Array; | 189 | readonly bytes: Uint8Array; |
| 190 | + readonly retryAfter?: string; | ||
| 187 | } | 191 | } |
| 188 | 192 | ||
| 189 | const SYSTEM_TIMER: RelayTimer = { | 193 | const SYSTEM_TIMER: RelayTimer = { |
| @@ -340,7 +344,11 @@ async function performRelay( | |||
| 340 | ): Promise<PrimRelayResponse> { | 344 | ): Promise<PrimRelayResponse> { |
| 341 | const response = await fetch(target, { | 345 | const response = await fetch(target, { |
| 342 | method: "GET", | 346 | method: "GET", |
| 343 | - headers: Object.freeze({ apikey: apiKey }), | 347 | + headers: Object.freeze({ |
| 348 | + apikey: apiKey, | ||
| 349 | + accept: "application/json", | ||
| 350 | + "accept-encoding": "gzip", | ||
| 351 | + }), | ||
| 344 | redirect: "manual", | 352 | redirect: "manual", |
| 345 | credentials: "omit", | 353 | credentials: "omit", |
| 346 | cache: "no-store", | 354 | cache: "no-store", |
| @@ -357,9 +365,24 @@ async function performRelay( | |||
| 357 | throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE); | 365 | throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE); |
| 358 | } | 366 | } |
| 359 | 367 | ||
| 368 | + let retryAfter: string | undefined; | ||
| 369 | + if (response.headers !== undefined) { | ||
| 370 | + let value: string | null; | ||
| 371 | + try { | ||
| 372 | + value = response.headers.get("retry-after"); | ||
| 373 | + } catch { | ||
| 374 | + throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE); | ||
| 375 | + } | ||
| 376 | + if (value !== null && typeof value !== "string") { | ||
| 377 | + throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE); | ||
| 378 | + } | ||
| 379 | + if (value !== null) retryAfter = value; | ||
| 380 | + } | ||
| 381 | + | ||
| 360 | return { | 382 | return { |
| 361 | status: response.status, | 383 | status: response.status, |
| 362 | bytes: await readBoundedBody(response.body, controller), | 384 | bytes: await readBoundedBody(response.body, controller), |
| 385 | + ...(retryAfter === undefined ? {} : { retryAfter }), | ||
| 363 | }; | 386 | }; |
| 364 | } | 387 | } |
| 365 | 388 | ||
| @@ -409,309 +432,3 @@ export async function relayPrimRequest( | |||
| 409 | } | 432 | } |
| 410 | } | 433 | } |
| 411 | 434 | ||
| 412 | -export interface DepartureRequestBody { | ||
| 413 | - readonly schemaVersion: typeof SCHEMA_VERSION; | ||
| 414 | - readonly requestId: string; | ||
| 415 | - readonly favoriteId: string; | ||
| 416 | - readonly serviceId: string; | ||
| 417 | -} | ||
| 418 | - | ||
| 419 | -export interface DepartureHandlerRequest { | ||
| 420 | - readonly authorization: unknown; | ||
| 421 | - readonly body: unknown; | ||
| 422 | -} | ||
| 423 | - | ||
| 424 | -export type PublicDepartureData = Omit<DepartureResult, "requestId" | "favoriteId">; | ||
| 425 | -export type PublicDepartureError = Omit<ErrorResult, "requestId" | "favoriteId">; | ||
| 426 | -export type DepartureResolverResult = PublicDepartureData | PublicDepartureError; | ||
| 427 | - | ||
| 428 | -export interface DepartureResolverRequest { | ||
| 429 | - readonly serviceId: string; | ||
| 430 | - readonly apiKey: string; | ||
| 431 | -} | ||
| 432 | - | ||
| 433 | -export type DepartureResolver = ( | ||
| 434 | - request: DepartureResolverRequest, | ||
| 435 | -) => Promise<DepartureResolverResult>; | ||
| 436 | - | ||
| 437 | -export interface DepartureHandlerDependencies { | ||
| 438 | - readonly resolve: DepartureResolver; | ||
| 439 | - readonly nowMilliseconds?: () => number; | ||
| 440 | -} | ||
| 441 | - | ||
| 442 | -export type DepartureHandlerResponse = DepartureResult | ErrorResult; | ||
| 443 | - | ||
| 444 | -interface DepartureBinding { | ||
| 445 | - readonly requestId: string; | ||
| 446 | - readonly favoriteId?: string; | ||
| 447 | -} | ||
| 448 | - | ||
| 449 | -interface CachedDeparture { | ||
| 450 | - readonly storedAtMilliseconds: number; | ||
| 451 | - readonly data: PublicDepartureData; | ||
| 452 | -} | ||
| 453 | - | ||
| 454 | -const DEPARTURE_REQUEST_KEYS = ["schemaVersion", "requestId", "favoriteId", "serviceId"] as const; | ||
| 455 | -const PUBLIC_DEPARTURE_KEYS = [ | ||
| 456 | - "schemaVersion", | ||
| 457 | - "fetchedAt", | ||
| 458 | - "sourceUpdatedAt", | ||
| 459 | - "freshness", | ||
| 460 | - "departures", | ||
| 461 | -] as const; | ||
| 462 | -const PUBLIC_ERROR_KEYS = [ | ||
| 463 | - "schemaVersion", | ||
| 464 | - "code", | ||
| 465 | - "occurredAt", | ||
| 466 | - "retryAfterSeconds", | ||
| 467 | -] as const; | ||
| 468 | -const INVALID_REQUEST_ID = "invalid-request"; | ||
| 469 | -const UINT32_MAX = 0xffff_ffff; | ||
| 470 | -const DEPARTURE_CACHE_FRESH_MS = CACHE_FRESH_SECONDS * 1_000; | ||
| 471 | -const DEPARTURE_CACHE = new Map<string, CachedDeparture>(); | ||
| 472 | - | ||
| 473 | -function record(value: unknown): value is Record<string, unknown> { | ||
| 474 | - return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 475 | -} | ||
| 476 | - | ||
| 477 | -function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean { | ||
| 478 | - return Object.keys(value).every((key) => allowed.includes(key)); | ||
| 479 | -} | ||
| 480 | - | ||
| 481 | -function boundedId(value: unknown): value is string { | ||
| 482 | - return typeof value === "string" | ||
| 483 | - && utf8Bytes(value) >= 1 | ||
| 484 | - && utf8Bytes(value) <= LIMITS.idUtf8Bytes; | ||
| 485 | -} | ||
| 486 | - | ||
| 487 | -function departureBinding(value: unknown): DepartureBinding { | ||
| 488 | - if (!record(value)) return { requestId: INVALID_REQUEST_ID }; | ||
| 489 | - | ||
| 490 | - try { | ||
| 491 | - const requestId = boundedId(value.requestId) ? value.requestId : INVALID_REQUEST_ID; | ||
| 492 | - const favoriteId = boundedId(value.favoriteId) ? value.favoriteId : undefined; | ||
| 493 | - return favoriteId === undefined ? { requestId } : { requestId, favoriteId }; | ||
| 494 | - } catch { | ||
| 495 | - return { requestId: INVALID_REQUEST_ID }; | ||
| 496 | - } | ||
| 497 | -} | ||
| 498 | - | ||
| 499 | -function isDepartureRequestBody(value: unknown): value is DepartureRequestBody { | ||
| 500 | - if (!record(value)) return false; | ||
| 501 | - | ||
| 502 | - try { | ||
| 503 | - return hasOnlyKeys(value, DEPARTURE_REQUEST_KEYS) | ||
| 504 | - && value.schemaVersion === SCHEMA_VERSION | ||
| 505 | - && boundedId(value.requestId) | ||
| 506 | - && boundedId(value.favoriteId) | ||
| 507 | - && boundedId(value.serviceId); | ||
| 508 | - } catch { | ||
| 509 | - return false; | ||
| 510 | - } | ||
| 511 | -} | ||
| 512 | - | ||
| 513 | -function isPublicDepartureData(value: unknown): value is PublicDepartureData { | ||
| 514 | - if (!record(value)) return false; | ||
| 515 | - | ||
| 516 | - try { | ||
| 517 | - return hasOnlyKeys(value, PUBLIC_DEPARTURE_KEYS) | ||
| 518 | - && isDepartureResult({ | ||
| 519 | - ...value, | ||
| 520 | - requestId: INVALID_REQUEST_ID, | ||
| 521 | - favoriteId: INVALID_REQUEST_ID, | ||
| 522 | - }); | ||
| 523 | - } catch { | ||
| 524 | - return false; | ||
| 525 | - } | ||
| 526 | -} | ||
| 527 | - | ||
| 528 | -function isPublicDepartureError(value: unknown): value is PublicDepartureError { | ||
| 529 | - if (!record(value)) return false; | ||
| 530 | - | ||
| 531 | - try { | ||
| 532 | - return hasOnlyKeys(value, PUBLIC_ERROR_KEYS) | ||
| 533 | - && isErrorResult({ ...value, requestId: INVALID_REQUEST_ID }); | ||
| 534 | - } catch { | ||
| 535 | - return false; | ||
| 536 | - } | ||
| 537 | -} | ||
| 538 | - | ||
| 539 | -function nowMilliseconds(dependencies: DepartureHandlerDependencies): number { | ||
| 540 | - const fallback = Date.now(); | ||
| 541 | - try { | ||
| 542 | - const value = dependencies.nowMilliseconds?.() ?? fallback; | ||
| 543 | - return Number.isFinite(value) && value >= 0 ? value : fallback; | ||
| 544 | - } catch { | ||
| 545 | - return fallback; | ||
| 546 | - } | ||
| 547 | -} | ||
| 548 | - | ||
| 549 | -function occurredAt(milliseconds: number): number { | ||
| 550 | - return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | ||
| 551 | -} | ||
| 552 | - | ||
| 553 | -function stableError( | ||
| 554 | - binding: DepartureBinding, | ||
| 555 | - code: ErrorCode, | ||
| 556 | - milliseconds: number, | ||
| 557 | -): ErrorResult { | ||
| 558 | - const result: ErrorResult = { | ||
| 559 | - schemaVersion: SCHEMA_VERSION, | ||
| 560 | - requestId: binding.requestId, | ||
| 561 | - code, | ||
| 562 | - occurredAt: occurredAt(milliseconds), | ||
| 563 | - }; | ||
| 564 | - if (binding.favoriteId !== undefined) result.favoriteId = binding.favoriteId; | ||
| 565 | - return result; | ||
| 566 | -} | ||
| 567 | - | ||
| 568 | -function copyDeparture(value: Departure): Departure { | ||
| 569 | - const copy: Departure = { | ||
| 570 | - expectedAt: value.expectedAt, | ||
| 571 | - minutes: value.minutes, | ||
| 572 | - status: value.status, | ||
| 573 | - }; | ||
| 574 | - if (value.aimedAt !== undefined) copy.aimedAt = value.aimedAt; | ||
| 575 | - if (value.nextIntervalMinutes !== undefined) { | ||
| 576 | - copy.nextIntervalMinutes = value.nextIntervalMinutes; | ||
| 577 | - } | ||
| 578 | - return copy; | ||
| 579 | -} | ||
| 580 | - | ||
| 581 | -function normalizePublicDeparture(value: PublicDepartureData): PublicDepartureData { | ||
| 582 | - const normalized: PublicDepartureData = { | ||
| 583 | - schemaVersion: SCHEMA_VERSION, | ||
| 584 | - fetchedAt: value.fetchedAt, | ||
| 585 | - freshness: value.freshness, | ||
| 586 | - departures: value.departures.map(copyDeparture), | ||
| 587 | - }; | ||
| 588 | - if (value.sourceUpdatedAt !== undefined) { | ||
| 589 | - normalized.sourceUpdatedAt = value.sourceUpdatedAt; | ||
| 590 | - } | ||
| 591 | - return normalized; | ||
| 592 | -} | ||
| 593 | - | ||
| 594 | -function bindDeparture( | ||
| 595 | - data: PublicDepartureData, | ||
| 596 | - request: DepartureRequestBody, | ||
| 597 | -): DepartureResult { | ||
| 598 | - const result: DepartureResult = { | ||
| 599 | - schemaVersion: SCHEMA_VERSION, | ||
| 600 | - requestId: request.requestId, | ||
| 601 | - favoriteId: request.favoriteId, | ||
| 602 | - fetchedAt: data.fetchedAt, | ||
| 603 | - freshness: data.freshness, | ||
| 604 | - departures: data.departures.map(copyDeparture), | ||
| 605 | - }; | ||
| 606 | - if (data.sourceUpdatedAt !== undefined) result.sourceUpdatedAt = data.sourceUpdatedAt; | ||
| 607 | - return result; | ||
| 608 | -} | ||
| 609 | - | ||
| 610 | -function bindPublicError( | ||
| 611 | - error: PublicDepartureError, | ||
| 612 | - request: DepartureRequestBody, | ||
| 613 | -): ErrorResult { | ||
| 614 | - const result: ErrorResult = { | ||
| 615 | - schemaVersion: SCHEMA_VERSION, | ||
| 616 | - requestId: request.requestId, | ||
| 617 | - favoriteId: request.favoriteId, | ||
| 618 | - code: error.code, | ||
| 619 | - occurredAt: error.occurredAt, | ||
| 620 | - }; | ||
| 621 | - if (error.retryAfterSeconds !== undefined) { | ||
| 622 | - result.retryAfterSeconds = error.retryAfterSeconds; | ||
| 623 | - } | ||
| 624 | - return result; | ||
| 625 | -} | ||
| 626 | - | ||
| 627 | -function cachedDeparture( | ||
| 628 | - serviceId: string, | ||
| 629 | - milliseconds: number, | ||
| 630 | -): PublicDepartureData | undefined { | ||
| 631 | - const cached = DEPARTURE_CACHE.get(serviceId); | ||
| 632 | - if (cached === undefined) return undefined; | ||
| 633 | - | ||
| 634 | - const age = milliseconds - cached.storedAtMilliseconds; | ||
| 635 | - if (age < 0 || age >= DEPARTURE_CACHE_FRESH_MS) { | ||
| 636 | - DEPARTURE_CACHE.delete(serviceId); | ||
| 637 | - return undefined; | ||
| 638 | - } | ||
| 639 | - return cached.data; | ||
| 640 | -} | ||
| 641 | - | ||
| 642 | -export async function handleDepartureRequest( | ||
| 643 | - request: DepartureHandlerRequest, | ||
| 644 | - dependencies: DepartureHandlerDependencies, | ||
| 645 | -): Promise<DepartureHandlerResponse> { | ||
| 646 | - let authorization: unknown; | ||
| 647 | - let bodyValue: unknown; | ||
| 648 | - try { | ||
| 649 | - authorization = request.authorization; | ||
| 650 | - bodyValue = request.body; | ||
| 651 | - } catch { | ||
| 652 | - authorization = undefined; | ||
| 653 | - bodyValue = undefined; | ||
| 654 | - } | ||
| 655 | - | ||
| 656 | - const binding = departureBinding(bodyValue); | ||
| 657 | - let apiKey: string; | ||
| 658 | - try { | ||
| 659 | - apiKey = parseBearerAuthorization(authorization); | ||
| 660 | - } catch (failure) { | ||
| 661 | - const code: ErrorCode = failure instanceof BackendBoundaryError | ||
| 662 | - && failure.code === BACKEND_ERROR_CODE.API_KEY_REQUIRED | ||
| 663 | - ? "API_KEY_REQUIRED" | ||
| 664 | - : "API_KEY_INVALID"; | ||
| 665 | - return stableError(binding, code, nowMilliseconds(dependencies)); | ||
| 666 | - } | ||
| 667 | - | ||
| 668 | - if (!isDepartureRequestBody(bodyValue)) { | ||
| 669 | - apiKey = ""; | ||
| 670 | - return stableError(binding, "INVALID_SERVICE", nowMilliseconds(dependencies)); | ||
| 671 | - } | ||
| 672 | - | ||
| 673 | - const requestBody = bodyValue; | ||
| 674 | - const currentMilliseconds = nowMilliseconds(dependencies); | ||
| 675 | - const cached = cachedDeparture(requestBody.serviceId, currentMilliseconds); | ||
| 676 | - if (cached !== undefined) { | ||
| 677 | - apiKey = ""; | ||
| 678 | - const result = bindDeparture(cached, requestBody); | ||
| 679 | - return isDepartureResult(result) | ||
| 680 | - ? result | ||
| 681 | - : stableError(binding, "INVALID_RESPONSE", currentMilliseconds); | ||
| 682 | - } | ||
| 683 | - | ||
| 684 | - let resolved: unknown; | ||
| 685 | - try { | ||
| 686 | - resolved = await dependencies.resolve({ | ||
| 687 | - serviceId: requestBody.serviceId, | ||
| 688 | - apiKey, | ||
| 689 | - }); | ||
| 690 | - } catch { | ||
| 691 | - return stableError(binding, "SOURCE_UNAVAILABLE", nowMilliseconds(dependencies)); | ||
| 692 | - } finally { | ||
| 693 | - apiKey = ""; | ||
| 694 | - } | ||
| 695 | - | ||
| 696 | - if (isPublicDepartureData(resolved)) { | ||
| 697 | - const normalized = normalizePublicDeparture(resolved); | ||
| 698 | - const storedAtMilliseconds = nowMilliseconds(dependencies); | ||
| 699 | - DEPARTURE_CACHE.set(requestBody.serviceId, { | ||
| 700 | - storedAtMilliseconds, | ||
| 701 | - data: normalized, | ||
| 702 | - }); | ||
| 703 | - const result = bindDeparture(normalized, requestBody); | ||
| 704 | - return isDepartureResult(result) | ||
| 705 | - ? result | ||
| 706 | - : stableError(binding, "INVALID_RESPONSE", storedAtMilliseconds); | ||
| 707 | - } | ||
| 708 | - | ||
| 709 | - if (isPublicDepartureError(resolved)) { | ||
| 710 | - const error = bindPublicError(resolved, requestBody); | ||
| 711 | - return isErrorResult(error) | ||
| 712 | - ? error | ||
| 713 | - : stableError(binding, "INVALID_RESPONSE", nowMilliseconds(dependencies)); | ||
| 714 | - } | ||
| 715 | - | ||
| 716 | - return stableError(binding, "INVALID_RESPONSE", nowMilliseconds(dependencies)); | ||
| 717 | -} | ||
added
packages/backend/src/prim-departures.ts +258 -0 | new file mode 100644 | ||
| @@ -0,0 +1,258 @@ | ||
| 1 | +import { | |
| 2 | + LIMITS, | |
| 3 | + PRIM_ORIGIN, | |
| 4 | + SCHEMA_VERSION, | |
| 5 | + type Departure, | |
| 6 | + type DepartureStatus, | |
| 7 | + type Freshness, | |
| 8 | +} from "../../contracts/src/index.ts"; | |
| 9 | + | |
| 10 | +export interface PrimServiceResolution { | |
| 11 | + readonly monitoringRef: string; | |
| 12 | + readonly lineRef: string; | |
| 13 | + readonly directionId: string; | |
| 14 | + readonly destinationRef: string; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export interface PrimNormalizedDepartureData { | |
| 18 | + readonly schemaVersion: typeof SCHEMA_VERSION; | |
| 19 | + readonly fetchedAt: number; | |
| 20 | + readonly sourceUpdatedAt?: number; | |
| 21 | + readonly freshness: Freshness; | |
| 22 | + readonly departures: readonly Departure[]; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export class PrimNormalizationError extends Error { | |
| 26 | + constructor(message: string) { | |
| 27 | + super(message); | |
| 28 | + this.name = "PrimNormalizationError"; | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +interface PrimVisit { | |
| 33 | + readonly expectedAt: number; | |
| 34 | + readonly aimedAt?: number; | |
| 35 | + readonly hasExpected: boolean; | |
| 36 | + readonly status: DepartureStatus; | |
| 37 | + readonly recordedAt: number; | |
| 38 | +} | |
| 39 | + | |
| 40 | +const STATUS_BY_SOURCE_VALUE: Readonly<Record<string, DepartureStatus>> = { | |
| 41 | + cancelled: "CANCELLED", | |
| 42 | + delayed: "DELAYED", | |
| 43 | + ontime: "ON_TIME", | |
| 44 | +}; | |
| 45 | + | |
| 46 | +const RFC3339_EXPLICIT_OFFSET = | |
| 47 | + /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(Z|([+-])([0-9]{2}):([0-9]{2}))$/u; | |
| 48 | + | |
| 49 | +const RETRY_AFTER_DELTA = /^[0-9]+$/u; | |
| 50 | +const RETRY_AFTER_MAX_SECONDS = 86_400; | |
| 51 | + | |
| 52 | +function fail(reason: string): never { | |
| 53 | + throw new PrimNormalizationError(`Invalid PRIM stop-monitoring response: ${reason}`); | |
| 54 | +} | |
| 55 | + | |
| 56 | +function record(value: unknown): value is Record<string, unknown> { | |
| 57 | + return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 58 | +} | |
| 59 | +function optionalRef(value: unknown, name: string): string | undefined { | |
| 60 | + if (value === undefined) return undefined; | |
| 61 | + if (typeof value === "string") return requireRef(value, name); | |
| 62 | + if (record(value)) return requireRef(value.value, `${name}.value`); | |
| 63 | + return fail(`${name} must be a string or reference object`); | |
| 64 | +} | |
| 65 | + | |
| 66 | +function requiredRef(value: unknown, name: string): string { | |
| 67 | + return optionalRef(value, name) ?? fail(`${name} is required`); | |
| 68 | +} | |
| 69 | + | |
| 70 | + | |
| 71 | +function requireRef(value: unknown, name: string): string { | |
| 72 | + if (typeof value !== "string" || value.length === 0) fail(`${name} must be a non-empty string`); | |
| 73 | + return value; | |
| 74 | +} | |
| 75 | + | |
| 76 | +function epochSeconds(value: unknown, name: string): number { | |
| 77 | + if (typeof value !== "string") fail(`${name} must be an ISO timestamp string`); | |
| 78 | + const match = RFC3339_EXPLICIT_OFFSET.exec(value); | |
| 79 | + if (match === null || match[0].length !== value.length) { | |
| 80 | + fail(`${name} is not a valid contract timestamp`); | |
| 81 | + } | |
| 82 | + | |
| 83 | + const year = Number(match[1]); | |
| 84 | + const month = Number(match[2]); | |
| 85 | + const day = Number(match[3]); | |
| 86 | + const hour = Number(match[4]); | |
| 87 | + const minute = Number(match[5]); | |
| 88 | + const second = Number(match[6]); | |
| 89 | + const offsetHour = match[9] === undefined ? 0 : Number(match[9]); | |
| 90 | + const offsetMinute = match[10] === undefined ? 0 : Number(match[10]); | |
| 91 | + const maximumDay = month === 2 | |
| 92 | + ? year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28 | |
| 93 | + : month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; | |
| 94 | + if ( | |
| 95 | + year < 1969 | |
| 96 | + || year > 2106 | |
| 97 | + || month < 1 | |
| 98 | + || month > 12 | |
| 99 | + || day < 1 | |
| 100 | + || day > maximumDay | |
| 101 | + || hour > 23 | |
| 102 | + || minute > 59 | |
| 103 | + || second > 59 | |
| 104 | + || offsetHour > 23 | |
| 105 | + || offsetMinute > 59 | |
| 106 | + ) { | |
| 107 | + fail(`${name} is not a valid contract timestamp`); | |
| 108 | + } | |
| 109 | + | |
| 110 | + const offsetDirection = match[8] === "-" ? -1 : 1; | |
| 111 | + const offsetSeconds = offsetDirection * (offsetHour * 60 + offsetMinute) * 60; | |
| 112 | + const seconds = Date.UTC(year, month - 1, day, hour, minute, second) / 1_000 | |
| 113 | + - offsetSeconds; | |
| 114 | + if (!Number.isInteger(seconds) || seconds < 0 || seconds > 0xffff_ffff) { | |
| 115 | + fail(`${name} is not a valid contract timestamp`); | |
| 116 | + } | |
| 117 | + return seconds; | |
| 118 | +} | |
| 119 | + | |
| 120 | +function optionalEpochSeconds(value: unknown, name: string): number | undefined { | |
| 121 | + return value === undefined ? undefined : epochSeconds(value, name); | |
| 122 | +} | |
| 123 | + | |
| 124 | +function sourceStatus(value: unknown): DepartureStatus { | |
| 125 | + if (typeof value !== "string") return "UNKNOWN"; | |
| 126 | + const status: DepartureStatus | undefined = STATUS_BY_SOURCE_VALUE[value.toLowerCase()]; | |
| 127 | + return status ?? "UNKNOWN"; | |
| 128 | +} | |
| 129 | + | |
| 130 | +function matchedJourney( | |
| 131 | + visit: Record<string, unknown>, | |
| 132 | + resolution: PrimServiceResolution, | |
| 133 | +): Record<string, unknown> | undefined { | |
| 134 | + epochSeconds(visit.RecordedAtTime, "MonitoredStopVisit.RecordedAtTime"); | |
| 135 | + const monitoringRef = requiredRef(visit.MonitoringRef, "MonitoredStopVisit.MonitoringRef"); | |
| 136 | + const journey = visit.MonitoredVehicleJourney; | |
| 137 | + if (!record(journey)) fail("MonitoredStopVisit.MonitoredVehicleJourney must be an object"); | |
| 138 | + const lineRef = requiredRef(journey.LineRef, "MonitoredVehicleJourney.LineRef"); | |
| 139 | + if (!record(journey.MonitoredCall)) fail("MonitoredVehicleJourney.MonitoredCall must be an object"); | |
| 140 | + if (monitoringRef !== resolution.monitoringRef || lineRef !== resolution.lineRef) return undefined; | |
| 141 | + const directionRef = optionalRef(journey.DirectionRef, "MonitoredVehicleJourney.DirectionRef"); | |
| 142 | + if (directionRef !== undefined && directionRef !== resolution.directionId) return undefined; | |
| 143 | + const destinationRef = optionalRef(journey.DestinationRef, "MonitoredVehicleJourney.DestinationRef"); | |
| 144 | + if (destinationRef !== undefined && destinationRef !== resolution.destinationRef) return undefined; | |
| 145 | + return journey; | |
| 146 | +} | |
| 147 | + | |
| 148 | +function parseMatchedVisit(journey: Record<string, unknown>, recordedAt: number): PrimVisit { | |
| 149 | + const call = record(journey.MonitoredCall) ? journey.MonitoredCall : fail("MonitoredVehicleJourney.MonitoredCall must be an object"); | |
| 150 | + const expected = optionalEpochSeconds(call.ExpectedDepartureTime, "MonitoredCall.ExpectedDepartureTime"); | |
| 151 | + const aimed = optionalEpochSeconds(call.AimedDepartureTime, "MonitoredCall.AimedDepartureTime"); | |
| 152 | + const expectedAt = expected ?? aimed; | |
| 153 | + if (expectedAt === undefined) fail("MonitoredCall requires ExpectedDepartureTime or AimedDepartureTime"); | |
| 154 | + return { | |
| 155 | + expectedAt, | |
| 156 | + ...(aimed === undefined ? {} : { aimedAt: aimed }), | |
| 157 | + hasExpected: expected !== undefined, | |
| 158 | + status: sourceStatus(call.DepartureStatus), | |
| 159 | + recordedAt, | |
| 160 | + }; | |
| 161 | +} | |
| 162 | + | |
| 163 | +export function normalizePrimDepartureResponse( | |
| 164 | + value: unknown, | |
| 165 | + resolution: PrimServiceResolution, | |
| 166 | + fetchedAt: number, | |
| 167 | +): PrimNormalizedDepartureData { | |
| 168 | + requireRef(resolution.monitoringRef, "monitoringRef"); | |
| 169 | + requireRef(resolution.lineRef, "lineRef"); | |
| 170 | + requireRef(resolution.directionId, "directionId"); | |
| 171 | + requireRef(resolution.destinationRef, "destinationRef"); | |
| 172 | + if (!Number.isInteger(fetchedAt) || fetchedAt < 0 || fetchedAt > 0xffff_ffff) { | |
| 173 | + fail("fetchedAt must be a contract epoch-seconds integer"); | |
| 174 | + } | |
| 175 | + | |
| 176 | + const envelope = record(value) ? value : fail("response must be a JSON object"); | |
| 177 | + const siri = record(envelope.Siri) ? envelope.Siri : fail("Siri must be an object"); | |
| 178 | + const serviceDelivery = record(siri.ServiceDelivery) ? siri.ServiceDelivery : fail("Siri.ServiceDelivery must be an object"); | |
| 179 | + const serviceTimestamp = optionalEpochSeconds( | |
| 180 | + serviceDelivery.ResponseTimestamp, | |
| 181 | + "ServiceDelivery.ResponseTimestamp", | |
| 182 | + ); | |
| 183 | + const deliveries = serviceDelivery.StopMonitoringDelivery; | |
| 184 | + if (!Array.isArray(deliveries)) fail("ServiceDelivery.StopMonitoringDelivery must be an array"); | |
| 185 | + | |
| 186 | + const matched: PrimVisit[] = []; | |
| 187 | + let sourceUpdatedAt: number | undefined = serviceTimestamp; | |
| 188 | + for (const entry of deliveries) { | |
| 189 | + const stopDelivery = record(entry) ? entry : fail("StopMonitoringDelivery entry must be an object"); | |
| 190 | + const responseTimestamp = optionalEpochSeconds(stopDelivery.ResponseTimestamp, "ResponseTimestamp"); | |
| 191 | + if (responseTimestamp !== undefined && (sourceUpdatedAt === undefined || responseTimestamp > sourceUpdatedAt)) { | |
| 192 | + sourceUpdatedAt = responseTimestamp; | |
| 193 | + } | |
| 194 | + const visits = stopDelivery.MonitoredStopVisit; | |
| 195 | + if (visits === undefined) continue; | |
| 196 | + if (!Array.isArray(visits)) fail("StopMonitoringDelivery.MonitoredStopVisit must be an array"); | |
| 197 | + for (const candidate of visits) { | |
| 198 | + const visit = record(candidate) ? candidate : fail("MonitoredStopVisit entry must be an object"); | |
| 199 | + const journey = matchedJourney(visit, resolution); | |
| 200 | + if (journey === undefined) continue; | |
| 201 | + const recordedAt = epochSeconds(visit.RecordedAtTime, "MonitoredStopVisit.RecordedAtTime"); | |
| 202 | + if (sourceUpdatedAt === undefined || recordedAt > sourceUpdatedAt) sourceUpdatedAt = recordedAt; | |
| 203 | + matched.push(parseMatchedVisit(journey, recordedAt)); | |
| 204 | + } | |
| 205 | + } | |
| 206 | + if (matched.length === 0) fail("no MonitoredStopVisit matches the resolved service"); | |
| 207 | + | |
| 208 | + matched.sort((left, right) => left.expectedAt - right.expectedAt); | |
| 209 | + const bounded = matched.slice(0, LIMITS.departures); | |
| 210 | + const realtimeCount = bounded.filter((visit) => visit.hasExpected).length; | |
| 211 | + const freshness: Freshness = realtimeCount === bounded.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED"; | |
| 212 | + | |
| 213 | + const departures: Departure[] = bounded.map((visit) => { | |
| 214 | + const departure: Departure = { | |
| 215 | + expectedAt: visit.expectedAt, | |
| 216 | + minutes: Math.max(0, Math.ceil((visit.expectedAt - fetchedAt) / 60)), | |
| 217 | + status: visit.status, | |
| 218 | + }; | |
| 219 | + if (visit.aimedAt !== undefined) departure.aimedAt = visit.aimedAt; | |
| 220 | + return departure; | |
| 221 | + }); | |
| 222 | + for (const [index, visit] of bounded.entries()) { | |
| 223 | + if (visit.status === "CANCELLED") continue; | |
| 224 | + const next = bounded.slice(index + 1).find((candidate) => candidate.status !== "CANCELLED"); | |
| 225 | + if (next === undefined) break; | |
| 226 | + const interval = Math.round((next.expectedAt - visit.expectedAt) / 60); | |
| 227 | + if (interval > 0) departures[index].nextIntervalMinutes = interval; | |
| 228 | + } | |
| 229 | + for (const departure of departures) Object.freeze(departure); | |
| 230 | + | |
| 231 | + return Object.freeze({ | |
| 232 | + schemaVersion: SCHEMA_VERSION, | |
| 233 | + fetchedAt, | |
| 234 | + ...(sourceUpdatedAt === undefined ? {} : { sourceUpdatedAt }), | |
| 235 | + freshness, | |
| 236 | + departures: Object.freeze(departures), | |
| 237 | + }); | |
| 238 | +} | |
| 239 | + | |
| 240 | +export function buildPrimStopMonitoringUrl(resolution: PrimServiceResolution): URL { | |
| 241 | + requireRef(resolution.monitoringRef, "monitoringRef"); | |
| 242 | + requireRef(resolution.lineRef, "lineRef"); | |
| 243 | + requireRef(resolution.directionId, "directionId"); | |
| 244 | + requireRef(resolution.destinationRef, "destinationRef"); | |
| 245 | + const url = new URL("/marketplace/stop-monitoring", PRIM_ORIGIN); | |
| 246 | + url.searchParams.set("MonitoringRef", resolution.monitoringRef); | |
| 247 | + url.searchParams.set("LineRef", resolution.lineRef); | |
| 248 | + return url; | |
| 249 | +} | |
| 250 | + | |
| 251 | +export function parseRetryAfterSeconds(value: unknown): number | undefined { | |
| 252 | + if (typeof value === "string") { | |
| 253 | + if (!RETRY_AFTER_DELTA.test(value)) return undefined; | |
| 254 | + value = Number(value); | |
| 255 | + } | |
| 256 | + if (typeof value !== "number" || !Number.isInteger(value)) return undefined; | |
| 257 | + return value >= 0 && value <= RETRY_AFTER_MAX_SECONDS ? value : undefined; | |
| 258 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,258 @@ | |||
| 1 | +import { | ||
| 2 | + LIMITS, | ||
| 3 | + PRIM_ORIGIN, | ||
| 4 | + SCHEMA_VERSION, | ||
| 5 | + type Departure, | ||
| 6 | + type DepartureStatus, | ||
| 7 | + type Freshness, | ||
| 8 | +} from "../../contracts/src/index.ts"; | ||
| 9 | + | ||
| 10 | +export interface PrimServiceResolution { | ||
| 11 | + readonly monitoringRef: string; | ||
| 12 | + readonly lineRef: string; | ||
| 13 | + readonly directionId: string; | ||
| 14 | + readonly destinationRef: string; | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +export interface PrimNormalizedDepartureData { | ||
| 18 | + readonly schemaVersion: typeof SCHEMA_VERSION; | ||
| 19 | + readonly fetchedAt: number; | ||
| 20 | + readonly sourceUpdatedAt?: number; | ||
| 21 | + readonly freshness: Freshness; | ||
| 22 | + readonly departures: readonly Departure[]; | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +export class PrimNormalizationError extends Error { | ||
| 26 | + constructor(message: string) { | ||
| 27 | + super(message); | ||
| 28 | + this.name = "PrimNormalizationError"; | ||
| 29 | + } | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +interface PrimVisit { | ||
| 33 | + readonly expectedAt: number; | ||
| 34 | + readonly aimedAt?: number; | ||
| 35 | + readonly hasExpected: boolean; | ||
| 36 | + readonly status: DepartureStatus; | ||
| 37 | + readonly recordedAt: number; | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +const STATUS_BY_SOURCE_VALUE: Readonly<Record<string, DepartureStatus>> = { | ||
| 41 | + cancelled: "CANCELLED", | ||
| 42 | + delayed: "DELAYED", | ||
| 43 | + ontime: "ON_TIME", | ||
| 44 | +}; | ||
| 45 | + | ||
| 46 | +const RFC3339_EXPLICIT_OFFSET = | ||
| 47 | + /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(Z|([+-])([0-9]{2}):([0-9]{2}))$/u; | ||
| 48 | + | ||
| 49 | +const RETRY_AFTER_DELTA = /^[0-9]+$/u; | ||
| 50 | +const RETRY_AFTER_MAX_SECONDS = 86_400; | ||
| 51 | + | ||
| 52 | +function fail(reason: string): never { | ||
| 53 | + throw new PrimNormalizationError(`Invalid PRIM stop-monitoring response: ${reason}`); | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +function record(value: unknown): value is Record<string, unknown> { | ||
| 57 | + return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 58 | +} | ||
| 59 | +function optionalRef(value: unknown, name: string): string | undefined { | ||
| 60 | + if (value === undefined) return undefined; | ||
| 61 | + if (typeof value === "string") return requireRef(value, name); | ||
| 62 | + if (record(value)) return requireRef(value.value, `${name}.value`); | ||
| 63 | + return fail(`${name} must be a string or reference object`); | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +function requiredRef(value: unknown, name: string): string { | ||
| 67 | + return optionalRef(value, name) ?? fail(`${name} is required`); | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | + | ||
| 71 | +function requireRef(value: unknown, name: string): string { | ||
| 72 | + if (typeof value !== "string" || value.length === 0) fail(`${name} must be a non-empty string`); | ||
| 73 | + return value; | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +function epochSeconds(value: unknown, name: string): number { | ||
| 77 | + if (typeof value !== "string") fail(`${name} must be an ISO timestamp string`); | ||
| 78 | + const match = RFC3339_EXPLICIT_OFFSET.exec(value); | ||
| 79 | + if (match === null || match[0].length !== value.length) { | ||
| 80 | + fail(`${name} is not a valid contract timestamp`); | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + const year = Number(match[1]); | ||
| 84 | + const month = Number(match[2]); | ||
| 85 | + const day = Number(match[3]); | ||
| 86 | + const hour = Number(match[4]); | ||
| 87 | + const minute = Number(match[5]); | ||
| 88 | + const second = Number(match[6]); | ||
| 89 | + const offsetHour = match[9] === undefined ? 0 : Number(match[9]); | ||
| 90 | + const offsetMinute = match[10] === undefined ? 0 : Number(match[10]); | ||
| 91 | + const maximumDay = month === 2 | ||
| 92 | + ? year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28 | ||
| 93 | + : month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; | ||
| 94 | + if ( | ||
| 95 | + year < 1969 | ||
| 96 | + || year > 2106 | ||
| 97 | + || month < 1 | ||
| 98 | + || month > 12 | ||
| 99 | + || day < 1 | ||
| 100 | + || day > maximumDay | ||
| 101 | + || hour > 23 | ||
| 102 | + || minute > 59 | ||
| 103 | + || second > 59 | ||
| 104 | + || offsetHour > 23 | ||
| 105 | + || offsetMinute > 59 | ||
| 106 | + ) { | ||
| 107 | + fail(`${name} is not a valid contract timestamp`); | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + const offsetDirection = match[8] === "-" ? -1 : 1; | ||
| 111 | + const offsetSeconds = offsetDirection * (offsetHour * 60 + offsetMinute) * 60; | ||
| 112 | + const seconds = Date.UTC(year, month - 1, day, hour, minute, second) / 1_000 | ||
| 113 | + - offsetSeconds; | ||
| 114 | + if (!Number.isInteger(seconds) || seconds < 0 || seconds > 0xffff_ffff) { | ||
| 115 | + fail(`${name} is not a valid contract timestamp`); | ||
| 116 | + } | ||
| 117 | + return seconds; | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +function optionalEpochSeconds(value: unknown, name: string): number | undefined { | ||
| 121 | + return value === undefined ? undefined : epochSeconds(value, name); | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +function sourceStatus(value: unknown): DepartureStatus { | ||
| 125 | + if (typeof value !== "string") return "UNKNOWN"; | ||
| 126 | + const status: DepartureStatus | undefined = STATUS_BY_SOURCE_VALUE[value.toLowerCase()]; | ||
| 127 | + return status ?? "UNKNOWN"; | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +function matchedJourney( | ||
| 131 | + visit: Record<string, unknown>, | ||
| 132 | + resolution: PrimServiceResolution, | ||
| 133 | +): Record<string, unknown> | undefined { | ||
| 134 | + epochSeconds(visit.RecordedAtTime, "MonitoredStopVisit.RecordedAtTime"); | ||
| 135 | + const monitoringRef = requiredRef(visit.MonitoringRef, "MonitoredStopVisit.MonitoringRef"); | ||
| 136 | + const journey = visit.MonitoredVehicleJourney; | ||
| 137 | + if (!record(journey)) fail("MonitoredStopVisit.MonitoredVehicleJourney must be an object"); | ||
| 138 | + const lineRef = requiredRef(journey.LineRef, "MonitoredVehicleJourney.LineRef"); | ||
| 139 | + if (!record(journey.MonitoredCall)) fail("MonitoredVehicleJourney.MonitoredCall must be an object"); | ||
| 140 | + if (monitoringRef !== resolution.monitoringRef || lineRef !== resolution.lineRef) return undefined; | ||
| 141 | + const directionRef = optionalRef(journey.DirectionRef, "MonitoredVehicleJourney.DirectionRef"); | ||
| 142 | + if (directionRef !== undefined && directionRef !== resolution.directionId) return undefined; | ||
| 143 | + const destinationRef = optionalRef(journey.DestinationRef, "MonitoredVehicleJourney.DestinationRef"); | ||
| 144 | + if (destinationRef !== undefined && destinationRef !== resolution.destinationRef) return undefined; | ||
| 145 | + return journey; | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +function parseMatchedVisit(journey: Record<string, unknown>, recordedAt: number): PrimVisit { | ||
| 149 | + const call = record(journey.MonitoredCall) ? journey.MonitoredCall : fail("MonitoredVehicleJourney.MonitoredCall must be an object"); | ||
| 150 | + const expected = optionalEpochSeconds(call.ExpectedDepartureTime, "MonitoredCall.ExpectedDepartureTime"); | ||
| 151 | + const aimed = optionalEpochSeconds(call.AimedDepartureTime, "MonitoredCall.AimedDepartureTime"); | ||
| 152 | + const expectedAt = expected ?? aimed; | ||
| 153 | + if (expectedAt === undefined) fail("MonitoredCall requires ExpectedDepartureTime or AimedDepartureTime"); | ||
| 154 | + return { | ||
| 155 | + expectedAt, | ||
| 156 | + ...(aimed === undefined ? {} : { aimedAt: aimed }), | ||
| 157 | + hasExpected: expected !== undefined, | ||
| 158 | + status: sourceStatus(call.DepartureStatus), | ||
| 159 | + recordedAt, | ||
| 160 | + }; | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +export function normalizePrimDepartureResponse( | ||
| 164 | + value: unknown, | ||
| 165 | + resolution: PrimServiceResolution, | ||
| 166 | + fetchedAt: number, | ||
| 167 | +): PrimNormalizedDepartureData { | ||
| 168 | + requireRef(resolution.monitoringRef, "monitoringRef"); | ||
| 169 | + requireRef(resolution.lineRef, "lineRef"); | ||
| 170 | + requireRef(resolution.directionId, "directionId"); | ||
| 171 | + requireRef(resolution.destinationRef, "destinationRef"); | ||
| 172 | + if (!Number.isInteger(fetchedAt) || fetchedAt < 0 || fetchedAt > 0xffff_ffff) { | ||
| 173 | + fail("fetchedAt must be a contract epoch-seconds integer"); | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + const envelope = record(value) ? value : fail("response must be a JSON object"); | ||
| 177 | + const siri = record(envelope.Siri) ? envelope.Siri : fail("Siri must be an object"); | ||
| 178 | + const serviceDelivery = record(siri.ServiceDelivery) ? siri.ServiceDelivery : fail("Siri.ServiceDelivery must be an object"); | ||
| 179 | + const serviceTimestamp = optionalEpochSeconds( | ||
| 180 | + serviceDelivery.ResponseTimestamp, | ||
| 181 | + "ServiceDelivery.ResponseTimestamp", | ||
| 182 | + ); | ||
| 183 | + const deliveries = serviceDelivery.StopMonitoringDelivery; | ||
| 184 | + if (!Array.isArray(deliveries)) fail("ServiceDelivery.StopMonitoringDelivery must be an array"); | ||
| 185 | + | ||
| 186 | + const matched: PrimVisit[] = []; | ||
| 187 | + let sourceUpdatedAt: number | undefined = serviceTimestamp; | ||
| 188 | + for (const entry of deliveries) { | ||
| 189 | + const stopDelivery = record(entry) ? entry : fail("StopMonitoringDelivery entry must be an object"); | ||
| 190 | + const responseTimestamp = optionalEpochSeconds(stopDelivery.ResponseTimestamp, "ResponseTimestamp"); | ||
| 191 | + if (responseTimestamp !== undefined && (sourceUpdatedAt === undefined || responseTimestamp > sourceUpdatedAt)) { | ||
| 192 | + sourceUpdatedAt = responseTimestamp; | ||
| 193 | + } | ||
| 194 | + const visits = stopDelivery.MonitoredStopVisit; | ||
| 195 | + if (visits === undefined) continue; | ||
| 196 | + if (!Array.isArray(visits)) fail("StopMonitoringDelivery.MonitoredStopVisit must be an array"); | ||
| 197 | + for (const candidate of visits) { | ||
| 198 | + const visit = record(candidate) ? candidate : fail("MonitoredStopVisit entry must be an object"); | ||
| 199 | + const journey = matchedJourney(visit, resolution); | ||
| 200 | + if (journey === undefined) continue; | ||
| 201 | + const recordedAt = epochSeconds(visit.RecordedAtTime, "MonitoredStopVisit.RecordedAtTime"); | ||
| 202 | + if (sourceUpdatedAt === undefined || recordedAt > sourceUpdatedAt) sourceUpdatedAt = recordedAt; | ||
| 203 | + matched.push(parseMatchedVisit(journey, recordedAt)); | ||
| 204 | + } | ||
| 205 | + } | ||
| 206 | + if (matched.length === 0) fail("no MonitoredStopVisit matches the resolved service"); | ||
| 207 | + | ||
| 208 | + matched.sort((left, right) => left.expectedAt - right.expectedAt); | ||
| 209 | + const bounded = matched.slice(0, LIMITS.departures); | ||
| 210 | + const realtimeCount = bounded.filter((visit) => visit.hasExpected).length; | ||
| 211 | + const freshness: Freshness = realtimeCount === bounded.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED"; | ||
| 212 | + | ||
| 213 | + const departures: Departure[] = bounded.map((visit) => { | ||
| 214 | + const departure: Departure = { | ||
| 215 | + expectedAt: visit.expectedAt, | ||
| 216 | + minutes: Math.max(0, Math.ceil((visit.expectedAt - fetchedAt) / 60)), | ||
| 217 | + status: visit.status, | ||
| 218 | + }; | ||
| 219 | + if (visit.aimedAt !== undefined) departure.aimedAt = visit.aimedAt; | ||
| 220 | + return departure; | ||
| 221 | + }); | ||
| 222 | + for (const [index, visit] of bounded.entries()) { | ||
| 223 | + if (visit.status === "CANCELLED") continue; | ||
| 224 | + const next = bounded.slice(index + 1).find((candidate) => candidate.status !== "CANCELLED"); | ||
| 225 | + if (next === undefined) break; | ||
| 226 | + const interval = Math.round((next.expectedAt - visit.expectedAt) / 60); | ||
| 227 | + if (interval > 0) departures[index].nextIntervalMinutes = interval; | ||
| 228 | + } | ||
| 229 | + for (const departure of departures) Object.freeze(departure); | ||
| 230 | + | ||
| 231 | + return Object.freeze({ | ||
| 232 | + schemaVersion: SCHEMA_VERSION, | ||
| 233 | + fetchedAt, | ||
| 234 | + ...(sourceUpdatedAt === undefined ? {} : { sourceUpdatedAt }), | ||
| 235 | + freshness, | ||
| 236 | + departures: Object.freeze(departures), | ||
| 237 | + }); | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +export function buildPrimStopMonitoringUrl(resolution: PrimServiceResolution): URL { | ||
| 241 | + requireRef(resolution.monitoringRef, "monitoringRef"); | ||
| 242 | + requireRef(resolution.lineRef, "lineRef"); | ||
| 243 | + requireRef(resolution.directionId, "directionId"); | ||
| 244 | + requireRef(resolution.destinationRef, "destinationRef"); | ||
| 245 | + const url = new URL("/marketplace/stop-monitoring", PRIM_ORIGIN); | ||
| 246 | + url.searchParams.set("MonitoringRef", resolution.monitoringRef); | ||
| 247 | + url.searchParams.set("LineRef", resolution.lineRef); | ||
| 248 | + return url; | ||
| 249 | +} | ||
| 250 | + | ||
| 251 | +export function parseRetryAfterSeconds(value: unknown): number | undefined { | ||
| 252 | + if (typeof value === "string") { | ||
| 253 | + if (!RETRY_AFTER_DELTA.test(value)) return undefined; | ||
| 254 | + value = Number(value); | ||
| 255 | + } | ||
| 256 | + if (typeof value !== "number" || !Number.isInteger(value)) return undefined; | ||
| 257 | + return value >= 0 && value <= RETRY_AFTER_MAX_SECONDS ? value : undefined; | ||
| 258 | +} | ||
modified
packages/backend/test/backend.test.ts +10 -2 | @@ -141,8 +141,16 @@ test("relay sends a GET to the canonical origin with the key only in apikey", as | ||
| 141 | 141 | |
| 142 | 142 | assert.equal(capturedTarget?.origin, PRIM_ORIGIN); |
| 143 | 143 | assert.equal(capturedTarget?.href.includes(TEST_KEY), false); |
| 144 | - assert.deepEqual(capturedInit?.headers, { apikey: TEST_KEY }); | |
| 145 | - assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), ["apikey"]); | |
| 144 | + assert.deepEqual(capturedInit?.headers, { | |
| 145 | + apikey: TEST_KEY, | |
| 146 | + accept: "application/json", | |
| 147 | + "accept-encoding": "gzip", | |
| 148 | + }); | |
| 149 | + assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), [ | |
| 150 | + "apikey", | |
| 151 | + "accept", | |
| 152 | + "accept-encoding", | |
| 153 | + ]); | |
| 146 | 154 | assert.equal(capturedInit?.method, "GET"); |
| 147 | 155 | assert.equal(capturedInit?.redirect, "manual"); |
| 148 | 156 | assert.equal(capturedInit?.credentials, "omit"); |
| @@ -141,8 +141,16 @@ test("relay sends a GET to the canonical origin with the key only in apikey", as | |||
| 141 | 141 | ||
| 142 | assert.equal(capturedTarget?.origin, PRIM_ORIGIN); | 142 | assert.equal(capturedTarget?.origin, PRIM_ORIGIN); |
| 143 | assert.equal(capturedTarget?.href.includes(TEST_KEY), false); | 143 | assert.equal(capturedTarget?.href.includes(TEST_KEY), false); |
| 144 | - assert.deepEqual(capturedInit?.headers, { apikey: TEST_KEY }); | 144 | + assert.deepEqual(capturedInit?.headers, { |
| 145 | - assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), ["apikey"]); | 145 | + apikey: TEST_KEY, |
| 146 | + accept: "application/json", | ||
| 147 | + "accept-encoding": "gzip", | ||
| 148 | + }); | ||
| 149 | + assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), [ | ||
| 150 | + "apikey", | ||
| 151 | + "accept", | ||
| 152 | + "accept-encoding", | ||
| 153 | + ]); | ||
| 146 | assert.equal(capturedInit?.method, "GET"); | 154 | assert.equal(capturedInit?.method, "GET"); |
| 147 | assert.equal(capturedInit?.redirect, "manual"); | 155 | assert.equal(capturedInit?.redirect, "manual"); |
| 148 | assert.equal(capturedInit?.credentials, "omit"); | 156 | assert.equal(capturedInit?.credentials, "omit"); |
added
packages/backend/test/departure-endpoint.test.ts +810 -0 | new file mode 100644 | ||
| @@ -0,0 +1,810 @@ | ||
| 1 | +import assert from "node:assert/strict"; | |
| 2 | +import { once } from "node:events"; | |
| 3 | +import { readFile } from "node:fs/promises"; | |
| 4 | +import { request as httpRequest } from "node:http"; | |
| 5 | +import { createConnection } from "node:net"; | |
| 6 | +import test from "node:test"; | |
| 7 | +import { | |
| 8 | + LIMITS, | |
| 9 | + PRIM_ORIGIN, | |
| 10 | + SCHEMA_VERSION, | |
| 11 | + isDepartureResult, | |
| 12 | + isErrorResult, | |
| 13 | + type DepartureResult, | |
| 14 | + type ErrorResult, | |
| 15 | +} from "../../contracts/src/index.ts"; | |
| 16 | +import { | |
| 17 | + DEPARTURE_ENDPOINT_LIMITS, | |
| 18 | + DEPARTURE_METRIC_NAME, | |
| 19 | + DEPARTURE_ROUTE, | |
| 20 | + createDepartureEndpoint, | |
| 21 | + createDepartureHttpServer, | |
| 22 | + type CatalogReader, | |
| 23 | + type CatalogServiceResolution, | |
| 24 | + type DepartureEndpoint, | |
| 25 | + type DepartureEndpointBody, | |
| 26 | + type DepartureEndpointRequest, | |
| 27 | + type DepartureEndpointResponse, | |
| 28 | + type DepartureMetricName, | |
| 29 | + type DepartureMetrics, | |
| 30 | + type PrimFetch, | |
| 31 | + type PrimFetchInit, | |
| 32 | + type PrimFetchResponse, | |
| 33 | + type RelayTimer, | |
| 34 | + type UpstreamLimiter, | |
| 35 | +} from "../src/index.ts"; | |
| 36 | + | |
| 37 | +const encoder = new TextEncoder(); | |
| 38 | +const decoder = new TextDecoder("utf-8", { fatal: true }); | |
| 39 | +const CLOCK_MS = Date.parse("2026-01-15T08:30:00Z"); | |
| 40 | +const CLOCK_SECONDS = CLOCK_MS / 1_000; | |
| 41 | +const SERVICE_ID = "svc-bus"; | |
| 42 | +const KEY_A = "endpoint-personal-key-a"; | |
| 43 | +const KEY_B = "endpoint-personal-key-b"; | |
| 44 | +const AUTHORIZATION_A = `Bearer ${KEY_A}`; | |
| 45 | +const AUTHORIZATION_B = `Bearer ${KEY_B}`; | |
| 46 | +const BUS_FIXTURE = readFile( | |
| 47 | + new URL("../../../fixtures/departures/prim/bus.json", import.meta.url), | |
| 48 | +); | |
| 49 | + | |
| 50 | +const RESOLUTION: CatalogServiceResolution = { | |
| 51 | + status: "RESOLVED", | |
| 52 | + monitoringRef: "IDFM:SP:1001", | |
| 53 | + lineRef: "IDFM:C1001", | |
| 54 | + directionId: "IDFM:1001D", | |
| 55 | + destinationRef: "IDFM:1001DST", | |
| 56 | + destinationLabel: "Fixture destination", | |
| 57 | +}; | |
| 58 | + | |
| 59 | +interface CallCounts { | |
| 60 | + catalog: number; | |
| 61 | + relay: number; | |
| 62 | +} | |
| 63 | + | |
| 64 | +interface RecordedMetric { | |
| 65 | + readonly name: DepartureMetricName; | |
| 66 | + readonly value: number; | |
| 67 | +} | |
| 68 | + | |
| 69 | +function epochSeconds(value: string): number { | |
| 70 | + return Date.parse(value) / 1_000; | |
| 71 | +} | |
| 72 | + | |
| 73 | +function catalogFixture(counts: CallCounts): CatalogReader { | |
| 74 | + return { | |
| 75 | + searchPlaces: () => [], | |
| 76 | + listServices: () => undefined, | |
| 77 | + resolveService(serviceId) { | |
| 78 | + counts.catalog += 1; | |
| 79 | + return serviceId === SERVICE_ID | |
| 80 | + ? RESOLUTION | |
| 81 | + : { status: "UNRESOLVED", code: "INVALID_SERVICE" }; | |
| 82 | + }, | |
| 83 | + }; | |
| 84 | +} | |
| 85 | + | |
| 86 | +function metricsRecorder(): DepartureMetrics & { readonly entries: RecordedMetric[] } { | |
| 87 | + const entries: RecordedMetric[] = []; | |
| 88 | + return { | |
| 89 | + entries, | |
| 90 | + record(name, value) { | |
| 91 | + entries.push({ name, value }); | |
| 92 | + }, | |
| 93 | + }; | |
| 94 | +} | |
| 95 | + | |
| 96 | +function primResponse( | |
| 97 | + chunks: readonly Uint8Array[], | |
| 98 | + status = 200, | |
| 99 | + retryAfter?: string, | |
| 100 | +): PrimFetchResponse { | |
| 101 | + return { | |
| 102 | + status, | |
| 103 | + headers: { | |
| 104 | + get(name) { | |
| 105 | + return name.toLowerCase() === "retry-after" && retryAfter !== undefined | |
| 106 | + ? retryAfter | |
| 107 | + : null; | |
| 108 | + }, | |
| 109 | + }, | |
| 110 | + body: new ReadableStream<Uint8Array>({ | |
| 111 | + start(controller) { | |
| 112 | + for (const chunk of chunks) controller.enqueue(chunk); | |
| 113 | + controller.close(); | |
| 114 | + }, | |
| 115 | + }), | |
| 116 | + }; | |
| 117 | +} | |
| 118 | + | |
| 119 | +function requestBytes( | |
| 120 | + requestId = "request-a", | |
| 121 | + favoriteId = "favorite-a", | |
| 122 | + serviceId = SERVICE_ID, | |
| 123 | +): Uint8Array { | |
| 124 | + return encoder.encode(JSON.stringify({ | |
| 125 | + schemaVersion: SCHEMA_VERSION, | |
| 126 | + requestId, | |
| 127 | + favoriteId, | |
| 128 | + serviceId, | |
| 129 | + })); | |
| 130 | +} | |
| 131 | + | |
| 132 | +function endpointRequest( | |
| 133 | + body: DepartureEndpointBody, | |
| 134 | + authorization = AUTHORIZATION_A, | |
| 135 | + headers: DepartureEndpointRequest["headers"] = [["Authorization", authorization]], | |
| 136 | +): DepartureEndpointRequest { | |
| 137 | + return { | |
| 138 | + method: "POST", | |
| 139 | + path: DEPARTURE_ROUTE, | |
| 140 | + headers, | |
| 141 | + body, | |
| 142 | + }; | |
| 143 | +} | |
| 144 | + | |
| 145 | +function decodeResponse(response: DepartureEndpointResponse): DepartureResult | ErrorResult { | |
| 146 | + assert.equal(response.headers["content-type"], "application/json"); | |
| 147 | + assert.ok(response.body instanceof Uint8Array); | |
| 148 | + const parsed: unknown = JSON.parse(decoder.decode(response.body)); | |
| 149 | + assert.ok(isDepartureResult(parsed) || isErrorResult(parsed)); | |
| 150 | + return parsed; | |
| 151 | +} | |
| 152 | + | |
| 153 | +function expectDeparture(response: DepartureEndpointResponse): DepartureResult { | |
| 154 | + const parsed = decodeResponse(response); | |
| 155 | + assert.equal(isDepartureResult(parsed), true); | |
| 156 | + if (!isDepartureResult(parsed)) assert.fail("expected a departure result"); | |
| 157 | + return parsed; | |
| 158 | +} | |
| 159 | + | |
| 160 | +function expectError(response: DepartureEndpointResponse): ErrorResult { | |
| 161 | + const parsed = decodeResponse(response); | |
| 162 | + assert.equal(isErrorResult(parsed), true); | |
| 163 | + if (!isErrorResult(parsed)) assert.fail("expected an error result"); | |
| 164 | + return parsed; | |
| 165 | +} | |
| 166 | + | |
| 167 | +function rawReadable(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> { | |
| 168 | + return new ReadableStream<Uint8Array>({ | |
| 169 | + start(controller) { | |
| 170 | + for (const chunk of chunks) controller.enqueue(chunk); | |
| 171 | + controller.close(); | |
| 172 | + }, | |
| 173 | + }); | |
| 174 | +} | |
| 175 | + | |
| 176 | +function rawAsyncIterable( | |
| 177 | + chunks: readonly Uint8Array[], | |
| 178 | + closed?: () => void, | |
| 179 | +): AsyncIterable<Uint8Array> { | |
| 180 | + return { | |
| 181 | + [Symbol.asyncIterator]() { | |
| 182 | + let index = 0; | |
| 183 | + return { | |
| 184 | + async next() { | |
| 185 | + const value = chunks[index]; | |
| 186 | + index += 1; | |
| 187 | + return value === undefined | |
| 188 | + ? { done: true as const, value: undefined } | |
| 189 | + : { done: false as const, value }; | |
| 190 | + }, | |
| 191 | + async return() { | |
| 192 | + closed?.(); | |
| 193 | + return { done: true as const, value: undefined }; | |
| 194 | + }, | |
| 195 | + }; | |
| 196 | + }, | |
| 197 | + }; | |
| 198 | +} | |
| 199 | + | |
| 200 | +function successfulEndpoint( | |
| 201 | + fixture: Uint8Array, | |
| 202 | + counts: CallCounts, | |
| 203 | +): DepartureEndpoint { | |
| 204 | + const fetch: PrimFetch = async () => { | |
| 205 | + counts.relay += 1; | |
| 206 | + return primResponse([fixture]); | |
| 207 | + }; | |
| 208 | + return createDepartureEndpoint({ | |
| 209 | + catalog: catalogFixture(counts), | |
| 210 | + fetch, | |
| 211 | + clock: () => CLOCK_MS, | |
| 212 | + }); | |
| 213 | +} | |
| 214 | + | |
| 215 | +function totalHeaderBytes(headers: DepartureEndpointRequest["headers"]): number { | |
| 216 | + return headers.reduce( | |
| 217 | + (total, [name, value]) => | |
| 218 | + total + encoder.encode(name).byteLength + 2 + encoder.encode(value).byteLength + 2, | |
| 219 | + 2, | |
| 220 | + ); | |
| 221 | +} | |
| 222 | + | |
| 223 | +interface HttpResponseCapture { | |
| 224 | + readonly status: number; | |
| 225 | + readonly contentType: string | string[] | undefined; | |
| 226 | + readonly body: Uint8Array; | |
| 227 | +} | |
| 228 | + | |
| 229 | +function postOverHttp( | |
| 230 | + port: number, | |
| 231 | + body: Uint8Array, | |
| 232 | + headers: Readonly<Record<string, string | string[]>>, | |
| 233 | +): Promise<HttpResponseCapture> { | |
| 234 | + const { promise, resolve, reject } = Promise.withResolvers<HttpResponseCapture>(); | |
| 235 | + const request = httpRequest({ | |
| 236 | + host: "127.0.0.1", | |
| 237 | + port, | |
| 238 | + method: "POST", | |
| 239 | + path: DEPARTURE_ROUTE, | |
| 240 | + headers, | |
| 241 | + }, (response) => { | |
| 242 | + const chunks: Uint8Array[] = []; | |
| 243 | + let byteLength = 0; | |
| 244 | + response.on("data", (chunk: Uint8Array) => { | |
| 245 | + chunks.push(chunk); | |
| 246 | + byteLength += chunk.byteLength; | |
| 247 | + }); | |
| 248 | + response.once("error", reject); | |
| 249 | + response.once("end", () => { | |
| 250 | + const bytes = new Uint8Array(byteLength); | |
| 251 | + let offset = 0; | |
| 252 | + for (const chunk of chunks) { | |
| 253 | + bytes.set(chunk, offset); | |
| 254 | + offset += chunk.byteLength; | |
| 255 | + } | |
| 256 | + resolve({ | |
| 257 | + status: response.statusCode ?? 0, | |
| 258 | + contentType: response.headers["content-type"], | |
| 259 | + body: bytes, | |
| 260 | + }); | |
| 261 | + }); | |
| 262 | + }); | |
| 263 | + request.once("error", reject); | |
| 264 | + request.end(body); | |
| 265 | + return promise; | |
| 266 | +} | |
| 267 | + | |
| 268 | +function rawPersistentRequest(port: number, requestText: string): Promise<string> { | |
| 269 | + const { promise, resolve, reject } = Promise.withResolvers<string>(); | |
| 270 | + const socket = createConnection({ host: "127.0.0.1", port }); | |
| 271 | + let responseText = ""; | |
| 272 | + let settled = false; | |
| 273 | + const fail = (error: Error) => { | |
| 274 | + if (settled) return; | |
| 275 | + settled = true; | |
| 276 | + socket.destroy(); | |
| 277 | + reject(error); | |
| 278 | + }; | |
| 279 | + | |
| 280 | + socket.setEncoding("latin1"); | |
| 281 | + // A protocol regression leaves this real socket open, so an event alone cannot bound the test. | |
| 282 | + socket.setTimeout(1_000, () => { | |
| 283 | + fail(new Error("raw HTTP connection did not close")); | |
| 284 | + }); | |
| 285 | + socket.on("data", (chunk: string) => { | |
| 286 | + responseText += chunk; | |
| 287 | + }); | |
| 288 | + socket.once("connect", () => { | |
| 289 | + socket.write(requestText); | |
| 290 | + }); | |
| 291 | + socket.once("error", fail); | |
| 292 | + socket.once("close", () => { | |
| 293 | + if (settled) return; | |
| 294 | + settled = true; | |
| 295 | + resolve(responseText); | |
| 296 | + }); | |
| 297 | + return promise; | |
| 298 | +} | |
| 299 | + | |
| 300 | +test("production endpoint composes catalog, bounded relay, normalization, binding, and shared cache", async () => { | |
| 301 | + const fixture = new Uint8Array(await BUS_FIXTURE); | |
| 302 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | |
| 303 | + const metrics = metricsRecorder(); | |
| 304 | + const captures: Array<{ readonly target: URL; readonly init: PrimFetchInit }> = []; | |
| 305 | + const logs: string[] = []; | |
| 306 | + let timerSets = 0; | |
| 307 | + let timerClears = 0; | |
| 308 | + let limiterRuns = 0; | |
| 309 | + const timerHandle = Object.freeze({ timer: "endpoint" }); | |
| 310 | + const timer: RelayTimer = { | |
| 311 | + set(_callback, delayMs) { | |
| 312 | + assert.equal(delayMs, LIMITS.httpTimeoutMs); | |
| 313 | + timerSets += 1; | |
| 314 | + return timerHandle; | |
| 315 | + }, | |
| 316 | + clear(handle) { | |
| 317 | + assert.equal(handle, timerHandle); | |
| 318 | + timerClears += 1; | |
| 319 | + }, | |
| 320 | + }; | |
| 321 | + const limiter: UpstreamLimiter = { | |
| 322 | + async run<T>(operation: () => Promise<T>): Promise<T> { | |
| 323 | + limiterRuns += 1; | |
| 324 | + return operation(); | |
| 325 | + }, | |
| 326 | + }; | |
| 327 | + const fetch: PrimFetch = async (target, init) => { | |
| 328 | + counts.relay += 1; | |
| 329 | + captures.push({ target, init }); | |
| 330 | + const midpoint = Math.floor(fixture.byteLength / 2); | |
| 331 | + return primResponse([fixture.subarray(0, midpoint), fixture.subarray(midpoint)]); | |
| 332 | + }; | |
| 333 | + const endpoint = createDepartureEndpoint({ | |
| 334 | + catalog: catalogFixture(counts), | |
| 335 | + fetch, | |
| 336 | + metrics, | |
| 337 | + clock: () => CLOCK_MS, | |
| 338 | + timer, | |
| 339 | + limiter, | |
| 340 | + logger(message) { | |
| 341 | + logs.push(message); | |
| 342 | + }, | |
| 343 | + }); | |
| 344 | + | |
| 345 | + const firstBytes = requestBytes(); | |
| 346 | + const midpoint = Math.floor(firstBytes.byteLength / 2); | |
| 347 | + const firstResponse = await endpoint(endpointRequest(rawReadable([ | |
| 348 | + firstBytes.subarray(0, midpoint), | |
| 349 | + firstBytes.subarray(midpoint), | |
| 350 | + ]))); | |
| 351 | + assert.equal(firstResponse.status, 200); | |
| 352 | + assert.deepEqual(expectDeparture(firstResponse), { | |
| 353 | + schemaVersion: SCHEMA_VERSION, | |
| 354 | + requestId: "request-a", | |
| 355 | + favoriteId: "favorite-a", | |
| 356 | + fetchedAt: CLOCK_SECONDS, | |
| 357 | + sourceUpdatedAt: CLOCK_SECONDS, | |
| 358 | + freshness: "REALTIME", | |
| 359 | + departures: [ | |
| 360 | + { | |
| 361 | + expectedAt: epochSeconds("2026-01-15T08:32:00Z"), | |
| 362 | + aimedAt: epochSeconds("2026-01-15T08:31:00Z"), | |
| 363 | + minutes: 2, | |
| 364 | + status: "ON_TIME", | |
| 365 | + nextIntervalMinutes: 6, | |
| 366 | + }, | |
| 367 | + { | |
| 368 | + expectedAt: epochSeconds("2026-01-15T08:38:00Z"), | |
| 369 | + aimedAt: epochSeconds("2026-01-15T08:37:30Z"), | |
| 370 | + minutes: 8, | |
| 371 | + status: "ON_TIME", | |
| 372 | + }, | |
| 373 | + ], | |
| 374 | + }); | |
| 375 | + | |
| 376 | + assert.equal(counts.catalog, 1); | |
| 377 | + assert.equal(counts.relay, 1); | |
| 378 | + assert.equal(limiterRuns, 1); | |
| 379 | + assert.equal(timerSets, 1); | |
| 380 | + assert.equal(timerClears, 1); | |
| 381 | + assert.deepEqual(logs, []); | |
| 382 | + assert.equal(captures.length, 1); | |
| 383 | + const firstRelay = captures[0]!; | |
| 384 | + assert.equal(firstRelay.target.origin, PRIM_ORIGIN); | |
| 385 | + assert.equal(firstRelay.target.hostname, "prim.iledefrance-mobilites.fr"); | |
| 386 | + assert.equal(firstRelay.target.pathname, "/marketplace/stop-monitoring"); | |
| 387 | + assert.deepEqual([...firstRelay.target.searchParams], [ | |
| 388 | + ["MonitoringRef", RESOLUTION.monitoringRef], | |
| 389 | + ["LineRef", RESOLUTION.lineRef], | |
| 390 | + ]); | |
| 391 | + assert.equal(firstRelay.target.href.includes(KEY_A), false); | |
| 392 | + assert.equal(firstRelay.init.method, "GET"); | |
| 393 | + assert.deepEqual(firstRelay.init.headers, { | |
| 394 | + apikey: KEY_A, | |
| 395 | + accept: "application/json", | |
| 396 | + "accept-encoding": "gzip", | |
| 397 | + }); | |
| 398 | + assert.deepEqual(Object.keys(firstRelay.init.headers), ["apikey", "accept", "accept-encoding"]); | |
| 399 | + assert.equal(firstRelay.init.redirect, "manual"); | |
| 400 | + assert.equal(firstRelay.init.credentials, "omit"); | |
| 401 | + assert.equal(firstRelay.init.cache, "no-store"); | |
| 402 | + | |
| 403 | + const secondResponse = await endpoint(endpointRequest( | |
| 404 | + rawAsyncIterable([requestBytes("request-b", "favorite-b")]), | |
| 405 | + AUTHORIZATION_B, | |
| 406 | + [["aUtHoRiZaTiOn", AUTHORIZATION_B]], | |
| 407 | + )); | |
| 408 | + assert.equal(secondResponse.status, 200); | |
| 409 | + const second = expectDeparture(secondResponse); | |
| 410 | + assert.equal(second.requestId, "request-b"); | |
| 411 | + assert.equal(second.favoriteId, "favorite-b"); | |
| 412 | + assert.equal(counts.catalog, 1); | |
| 413 | + assert.equal(counts.relay, 1); | |
| 414 | + assert.equal(limiterRuns, 1); | |
| 415 | + assert.equal(timerSets, 1); | |
| 416 | + assert.equal(timerClears, 1); | |
| 417 | + | |
| 418 | + const metricNames = metrics.entries.map(({ name }) => name); | |
| 419 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.CACHE_MISS)); | |
| 420 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.CACHE_HIT)); | |
| 421 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST)); | |
| 422 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.UPSTREAM_STATUS)); | |
| 423 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS)); | |
| 424 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES)); | |
| 425 | + assert.deepEqual( | |
| 426 | + metrics.entries | |
| 427 | + .filter(({ name }) => name === DEPARTURE_METRIC_NAME.UPSTREAM_STATUS) | |
| 428 | + .map(({ value }) => value), | |
| 429 | + [200], | |
| 430 | + ); | |
| 431 | + | |
| 432 | + for (const secret of [KEY_A, KEY_B, AUTHORIZATION_A, AUTHORIZATION_B]) { | |
| 433 | + assert.equal(decoder.decode(firstResponse.body).includes(secret), false); | |
| 434 | + assert.equal(decoder.decode(secondResponse.body).includes(secret), false); | |
| 435 | + assert.equal(JSON.stringify(metrics.entries).includes(secret), false); | |
| 436 | + assert.equal(JSON.stringify(logs).includes(secret), false); | |
| 437 | + } | |
| 438 | + assert.deepEqual(Object.keys(endpoint), []); | |
| 439 | +}); | |
| 440 | + | |
| 441 | +test("endpoint maps actual PRIM 401, 403, and 429 responses through JSON errors", async () => { | |
| 442 | + const fixture = new Uint8Array(await BUS_FIXTURE); | |
| 443 | + for (const status of [401, 403, 429] as const) { | |
| 444 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | |
| 445 | + const metrics = metricsRecorder(); | |
| 446 | + const fetch: PrimFetch = async () => { | |
| 447 | + counts.relay += 1; | |
| 448 | + return primResponse([fixture], status, status === 429 ? "37" : undefined); | |
| 449 | + }; | |
| 450 | + const endpoint = createDepartureEndpoint({ | |
| 451 | + catalog: catalogFixture(counts), | |
| 452 | + fetch, | |
| 453 | + metrics, | |
| 454 | + clock: () => CLOCK_MS, | |
| 455 | + }); | |
| 456 | + | |
| 457 | + const response = await endpoint(endpointRequest(requestBytes())); | |
| 458 | + const error = expectError(response); | |
| 459 | + assert.equal(response.status, status === 429 ? 429 : 401); | |
| 460 | + assert.equal(error.code, status === 429 ? "RATE_LIMITED" : "API_KEY_INVALID"); | |
| 461 | + assert.equal(error.requestId, "request-a"); | |
| 462 | + assert.equal(error.favoriteId, "favorite-a"); | |
| 463 | + assert.equal(error.occurredAt, CLOCK_SECONDS); | |
| 464 | + assert.equal(error.retryAfterSeconds, status === 429 ? 37 : undefined); | |
| 465 | + assert.equal(counts.catalog, 1); | |
| 466 | + assert.equal(counts.relay, 1); | |
| 467 | + assert.deepEqual( | |
| 468 | + metrics.entries | |
| 469 | + .filter(({ name }) => name === DEPARTURE_METRIC_NAME.UPSTREAM_STATUS) | |
| 470 | + .map(({ value }) => value), | |
| 471 | + [status], | |
| 472 | + ); | |
| 473 | + assert.equal(decoder.decode(response.body).includes(KEY_A), false); | |
| 474 | + assert.equal(JSON.stringify(metrics.entries).includes(KEY_A), false); | |
| 475 | + } | |
| 476 | +}); | |
| 477 | + | |
| 478 | +test("request body and total headers accept the exact byte limit and reject one byte more", async () => { | |
| 479 | + const fixture = new Uint8Array(await BUS_FIXTURE); | |
| 480 | + const compact = requestBytes(); | |
| 481 | + const bodyPadding = DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes - compact.byteLength; | |
| 482 | + assert.ok(bodyPadding >= 0); | |
| 483 | + const exactBody = encoder.encode(`${decoder.decode(compact)}${" ".repeat(bodyPadding)}`); | |
| 484 | + assert.equal(exactBody.byteLength, DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes); | |
| 485 | + | |
| 486 | + const exactBodyCounts: CallCounts = { catalog: 0, relay: 0 }; | |
| 487 | + const exactBodyEndpoint = successfulEndpoint(fixture, exactBodyCounts); | |
| 488 | + const exactBodyResponse = await exactBodyEndpoint(endpointRequest( | |
| 489 | + exactBody, | |
| 490 | + AUTHORIZATION_A, | |
| 491 | + [ | |
| 492 | + ["Authorization", AUTHORIZATION_A], | |
| 493 | + ["Content-Length", String(exactBody.byteLength)], | |
| 494 | + ], | |
| 495 | + )); | |
| 496 | + assert.equal(exactBodyResponse.status, 200); | |
| 497 | + assert.equal(exactBodyCounts.catalog, 1); | |
| 498 | + assert.equal(exactBodyCounts.relay, 1); | |
| 499 | + | |
| 500 | + const oversizedBodyCounts: CallCounts = { catalog: 0, relay: 0 }; | |
| 501 | + const oversizedBodyEndpoint = successfulEndpoint(fixture, oversizedBodyCounts); | |
| 502 | + const oversizedBody = new Uint8Array(DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes + 1); | |
| 503 | + const oversizedBodyResponse = await oversizedBodyEndpoint(endpointRequest(oversizedBody)); | |
| 504 | + assert.equal(oversizedBodyResponse.status, 400); | |
| 505 | + assert.equal(expectError(oversizedBodyResponse).code, "INVALID_SERVICE"); | |
| 506 | + assert.deepEqual(oversizedBodyCounts, { catalog: 0, relay: 0 }); | |
| 507 | + | |
| 508 | + const baseHeaders = [["Authorization", AUTHORIZATION_A]] as const; | |
| 509 | + const paddingName = "X-Padding"; | |
| 510 | + const paddingLength = DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes | |
| 511 | + - totalHeaderBytes([...baseHeaders, [paddingName, ""]]); | |
| 512 | + assert.ok(paddingLength >= 0); | |
| 513 | + const exactHeaders = [ | |
| 514 | + ...baseHeaders, | |
| 515 | + [paddingName, "x".repeat(paddingLength)] as const, | |
| 516 | + ]; | |
| 517 | + assert.equal(totalHeaderBytes(exactHeaders), DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes); | |
| 518 | + | |
| 519 | + const exactHeaderCounts: CallCounts = { catalog: 0, relay: 0 }; | |
| 520 | + const exactHeaderEndpoint = successfulEndpoint(fixture, exactHeaderCounts); | |
| 521 | + const exactHeaderResponse = await exactHeaderEndpoint(endpointRequest( | |
| 522 | + compact, | |
| 523 | + AUTHORIZATION_A, | |
| 524 | + exactHeaders, | |
| 525 | + )); | |
| 526 | + assert.equal(exactHeaderResponse.status, 200); | |
| 527 | + assert.deepEqual(exactHeaderCounts, { catalog: 1, relay: 1 }); | |
| 528 | + | |
| 529 | + const oversizedHeaderCounts: CallCounts = { catalog: 0, relay: 0 }; | |
| 530 | + const oversizedHeaderEndpoint = successfulEndpoint(fixture, oversizedHeaderCounts); | |
| 531 | + const oversizedHeaders = [ | |
| 532 | + ...baseHeaders, | |
| 533 | + [paddingName, "x".repeat(paddingLength + 1)] as const, | |
| 534 | + ]; | |
| 535 | + assert.equal( | |
| 536 | + totalHeaderBytes(oversizedHeaders), | |
| 537 | + DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes + 1, | |
| 538 | + ); | |
| 539 | + const oversizedHeaderResponse = await oversizedHeaderEndpoint(endpointRequest( | |
| 540 | + compact, | |
| 541 | + AUTHORIZATION_A, | |
| 542 | + oversizedHeaders, | |
| 543 | + )); | |
| 544 | + assert.equal(oversizedHeaderResponse.status, 400); | |
| 545 | + assert.equal(expectError(oversizedHeaderResponse).requestId, "invalid-request"); | |
| 546 | + assert.deepEqual(oversizedHeaderCounts, { catalog: 0, relay: 0 }); | |
| 547 | + | |
| 548 | + const manySmallHeaderCounts: CallCounts = { catalog: 0, relay: 0 }; | |
| 549 | + const manySmallHeaders = [ | |
| 550 | + ...baseHeaders, | |
| 551 | + ...Array.from( | |
| 552 | + { length: 300 }, | |
| 553 | + (_, index) => [`X-${index % 10}`, ""] as const, | |
| 554 | + ), | |
| 555 | + ]; | |
| 556 | + const nameAndValueBytes = manySmallHeaders.reduce( | |
| 557 | + (total, [name, value]) => | |
| 558 | + total + encoder.encode(name).byteLength + encoder.encode(value).byteLength, | |
| 559 | + 0, | |
| 560 | + ); | |
| 561 | + assert.ok(nameAndValueBytes < DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes); | |
| 562 | + assert.ok(totalHeaderBytes(manySmallHeaders) > DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes); | |
| 563 | + const manySmallHeaderResponse = await successfulEndpoint( | |
| 564 | + fixture, | |
| 565 | + manySmallHeaderCounts, | |
| 566 | + )(endpointRequest(compact, AUTHORIZATION_A, manySmallHeaders)); | |
| 567 | + assert.equal(manySmallHeaderResponse.status, 400); | |
| 568 | + assert.deepEqual(manySmallHeaderCounts, { catalog: 0, relay: 0 }); | |
| 569 | +}); | |
| 570 | + | |
| 571 | +test("Content-Length is strict, bounded before body access, and must match actual bytes", async () => { | |
| 572 | + const fixture = new Uint8Array(await BUS_FIXTURE); | |
| 573 | + const body = requestBytes(); | |
| 574 | + | |
| 575 | + for (const headers of [ | |
| 576 | + [["Authorization", AUTHORIZATION_A], ["Content-Length", "12x"]], | |
| 577 | + [["Authorization", AUTHORIZATION_A], ["Content-Length", "1"], ["content-length", "1"]], | |
| 578 | + ] as const) { | |
| 579 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | |
| 580 | + let iterated = false; | |
| 581 | + const lazyBody: AsyncIterable<Uint8Array> = { | |
| 582 | + [Symbol.asyncIterator]() { | |
| 583 | + iterated = true; | |
| 584 | + return rawAsyncIterable([body])[Symbol.asyncIterator](); | |
| 585 | + }, | |
| 586 | + }; | |
| 587 | + const response = await successfulEndpoint(fixture, counts)(endpointRequest( | |
| 588 | + lazyBody, | |
| 589 | + AUTHORIZATION_A, | |
| 590 | + headers, | |
| 591 | + )); | |
| 592 | + assert.equal(response.status, 400); | |
| 593 | + assert.equal(iterated, false); | |
| 594 | + assert.deepEqual(counts, { catalog: 0, relay: 0 }); | |
| 595 | + } | |
| 596 | + | |
| 597 | + const overLimitCounts: CallCounts = { catalog: 0, relay: 0 }; | |
| 598 | + let overLimitIterated = false; | |
| 599 | + const overLimitBody: AsyncIterable<Uint8Array> = { | |
| 600 | + [Symbol.asyncIterator]() { | |
| 601 | + overLimitIterated = true; | |
| 602 | + return rawAsyncIterable([body])[Symbol.asyncIterator](); | |
| 603 | + }, | |
| 604 | + }; | |
| 605 | + const overLimitResponse = await successfulEndpoint(fixture, overLimitCounts)(endpointRequest( | |
| 606 | + overLimitBody, | |
| 607 | + AUTHORIZATION_A, | |
| 608 | + [ | |
| 609 | + ["Authorization", AUTHORIZATION_A], | |
| 610 | + ["Content-Length", String(DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes + 1)], | |
| 611 | + ], | |
| 612 | + )); | |
| 613 | + assert.equal(overLimitResponse.status, 400); | |
| 614 | + assert.equal(overLimitIterated, false); | |
| 615 | + assert.deepEqual(overLimitCounts, { catalog: 0, relay: 0 }); | |
| 616 | + | |
| 617 | + for (const declared of [body.byteLength - 1, body.byteLength + 1]) { | |
| 618 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | |
| 619 | + const response = await successfulEndpoint(fixture, counts)(endpointRequest( | |
| 620 | + body, | |
| 621 | + AUTHORIZATION_A, | |
| 622 | + [ | |
| 623 | + ["Authorization", AUTHORIZATION_A], | |
| 624 | + ["Content-Length", String(declared)], | |
| 625 | + ], | |
| 626 | + )); | |
| 627 | + assert.equal(response.status, 400); | |
| 628 | + assert.deepEqual(counts, { catalog: 0, relay: 0 }); | |
| 629 | + } | |
| 630 | +}); | |
| 631 | + | |
| 632 | +test("overflowing streamed bodies are cancelled before JSON parsing or resolution", async () => { | |
| 633 | + const fixture = new Uint8Array(await BUS_FIXTURE); | |
| 634 | + const fullChunk = new Uint8Array(DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes); | |
| 635 | + | |
| 636 | + const streamCounts: CallCounts = { catalog: 0, relay: 0 }; | |
| 637 | + let streamCancelled = false; | |
| 638 | + const overflowingStream = new ReadableStream<Uint8Array>({ | |
| 639 | + start(controller) { | |
| 640 | + controller.enqueue(fullChunk); | |
| 641 | + controller.enqueue(Uint8Array.of(1)); | |
| 642 | + }, | |
| 643 | + cancel() { | |
| 644 | + streamCancelled = true; | |
| 645 | + }, | |
| 646 | + }); | |
| 647 | + const streamResponse = await successfulEndpoint(fixture, streamCounts)( | |
| 648 | + endpointRequest(overflowingStream), | |
| 649 | + ); | |
| 650 | + assert.equal(streamResponse.status, 400); | |
| 651 | + assert.equal(streamCancelled, true); | |
| 652 | + assert.deepEqual(streamCounts, { catalog: 0, relay: 0 }); | |
| 653 | + | |
| 654 | + const iterableCounts: CallCounts = { catalog: 0, relay: 0 }; | |
| 655 | + let iteratorClosed = false; | |
| 656 | + const iterableResponse = await successfulEndpoint(fixture, iterableCounts)(endpointRequest( | |
| 657 | + rawAsyncIterable([fullChunk, Uint8Array.of(1)], () => { | |
| 658 | + iteratorClosed = true; | |
| 659 | + }), | |
| 660 | + )); | |
| 661 | + assert.equal(iterableResponse.status, 400); | |
| 662 | + assert.equal(iteratorClosed, true); | |
| 663 | + assert.deepEqual(iterableCounts, { catalog: 0, relay: 0 }); | |
| 664 | +}); | |
| 665 | + | |
| 666 | +test("fatal UTF-8, JSON syntax, and duplicate or malformed Authorization reject without resolution", async () => { | |
| 667 | + const fixture = new Uint8Array(await BUS_FIXTURE); | |
| 668 | + const cases: ReadonlyArray<{ | |
| 669 | + readonly body: DepartureEndpointBody; | |
| 670 | + readonly headers: DepartureEndpointRequest["headers"]; | |
| 671 | + readonly status: 400 | 401; | |
| 672 | + readonly code: "INVALID_SERVICE" | "API_KEY_INVALID"; | |
| 673 | + }> = [ | |
| 674 | + { | |
| 675 | + body: Uint8Array.of(0xc3, 0x28), | |
| 676 | + headers: [["Authorization", AUTHORIZATION_A]], | |
| 677 | + status: 400, | |
| 678 | + code: "INVALID_SERVICE", | |
| 679 | + }, | |
| 680 | + { | |
| 681 | + body: encoder.encode("{\"schemaVersion\":"), | |
| 682 | + headers: [["Authorization", AUTHORIZATION_A]], | |
| 683 | + status: 400, | |
| 684 | + code: "INVALID_SERVICE", | |
| 685 | + }, | |
| 686 | + { | |
| 687 | + body: requestBytes(), | |
| 688 | + headers: [ | |
| 689 | + ["Authorization", AUTHORIZATION_A], | |
| 690 | + ["authorization", AUTHORIZATION_A], | |
| 691 | + ], | |
| 692 | + status: 401, | |
| 693 | + code: "API_KEY_INVALID", | |
| 694 | + }, | |
| 695 | + { | |
| 696 | + body: requestBytes(), | |
| 697 | + headers: [["Authorization", `bearer ${KEY_A}`]], | |
| 698 | + status: 401, | |
| 699 | + code: "API_KEY_INVALID", | |
| 700 | + }, | |
| 701 | + ]; | |
| 702 | + | |
| 703 | + for (const fixtureCase of cases) { | |
| 704 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | |
| 705 | + const response = await successfulEndpoint(fixture, counts)({ | |
| 706 | + method: "POST", | |
| 707 | + path: DEPARTURE_ROUTE, | |
| 708 | + headers: fixtureCase.headers, | |
| 709 | + body: fixtureCase.body, | |
| 710 | + }); | |
| 711 | + assert.equal(response.status, fixtureCase.status); | |
| 712 | + assert.equal(expectError(response).code, fixtureCase.code); | |
| 713 | + assert.deepEqual(counts, { catalog: 0, relay: 0 }); | |
| 714 | + } | |
| 715 | +}); | |
| 716 | + | |
| 717 | +test("endpoint preserves the domain handler's exact path and POST status semantics", async () => { | |
| 718 | + const fixture = new Uint8Array(await BUS_FIXTURE); | |
| 719 | + const cases = [ | |
| 720 | + { method: "POST", path: `${DEPARTURE_ROUTE}/`, status: 404 }, | |
| 721 | + { method: "GET", path: DEPARTURE_ROUTE, status: 405 }, | |
| 722 | + { method: "post", path: DEPARTURE_ROUTE, status: 405 }, | |
| 723 | + ] as const; | |
| 724 | + | |
| 725 | + for (const fixtureCase of cases) { | |
| 726 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | |
| 727 | + const endpoint = successfulEndpoint(fixture, counts); | |
| 728 | + const response = await endpoint({ | |
| 729 | + method: fixtureCase.method, | |
| 730 | + path: fixtureCase.path, | |
| 731 | + headers: [["Authorization", AUTHORIZATION_A]], | |
| 732 | + body: requestBytes(), | |
| 733 | + }); | |
| 734 | + assert.equal(response.status, fixtureCase.status); | |
| 735 | + assert.equal(expectError(response).code, "INVALID_SERVICE"); | |
| 736 | + assert.deepEqual(counts, { catalog: 0, relay: 0 }); | |
| 737 | + } | |
| 738 | +}); | |
| 739 | + | |
| 740 | +test("node:http adapter registers the real POST route and preserves duplicate headers", async () => { | |
| 741 | + const fixture = new Uint8Array(await BUS_FIXTURE); | |
| 742 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | |
| 743 | + const fetch: PrimFetch = async () => { | |
| 744 | + counts.relay += 1; | |
| 745 | + return primResponse([fixture]); | |
| 746 | + }; | |
| 747 | + const server = createDepartureHttpServer({ | |
| 748 | + catalog: catalogFixture(counts), | |
| 749 | + fetch, | |
| 750 | + clock: () => CLOCK_MS, | |
| 751 | + }); | |
| 752 | + assert.equal(server.listening, false); | |
| 753 | + | |
| 754 | + const listening = once(server, "listening"); | |
| 755 | + server.listen(0, "127.0.0.1"); | |
| 756 | + await listening; | |
| 757 | + try { | |
| 758 | + const address = server.address(); | |
| 759 | + assert.ok(address !== null && typeof address === "object"); | |
| 760 | + const firstBody = requestBytes("http-request-a", "http-favorite-a"); | |
| 761 | + const first = await postOverHttp(address.port, firstBody, { | |
| 762 | + Authorization: AUTHORIZATION_A, | |
| 763 | + "Content-Length": String(firstBody.byteLength), | |
| 764 | + }); | |
| 765 | + assert.equal(first.status, 200, decoder.decode(first.body)); | |
| 766 | + assert.equal(first.contentType, "application/json"); | |
| 767 | + const firstResult: unknown = JSON.parse(decoder.decode(first.body)); | |
| 768 | + assert.equal(isDepartureResult(firstResult), true); | |
| 769 | + if (!isDepartureResult(firstResult)) assert.fail("expected HTTP departure result"); | |
| 770 | + assert.equal(firstResult.requestId, "http-request-a"); | |
| 771 | + assert.equal(firstResult.favoriteId, "http-favorite-a"); | |
| 772 | + assert.equal(counts.catalog, 1); | |
| 773 | + assert.equal(counts.relay, 1); | |
| 774 | + | |
| 775 | + const duplicateBody = requestBytes("http-request-b", "http-favorite-b"); | |
| 776 | + const duplicate = await postOverHttp(address.port, duplicateBody, { | |
| 777 | + Authorization: [AUTHORIZATION_B, AUTHORIZATION_B], | |
| 778 | + "Content-Length": String(duplicateBody.byteLength), | |
| 779 | + }); | |
| 780 | + assert.equal(duplicate.status, 401); | |
| 781 | + assert.equal(duplicate.contentType, "application/json"); | |
| 782 | + const duplicateResult: unknown = JSON.parse(decoder.decode(duplicate.body)); | |
| 783 | + assert.equal(isErrorResult(duplicateResult), true); | |
| 784 | + if (!isErrorResult(duplicateResult)) assert.fail("expected duplicate-header error"); | |
| 785 | + assert.equal(duplicateResult.code, "API_KEY_INVALID"); | |
| 786 | + assert.equal(duplicateResult.requestId, "http-request-b"); | |
| 787 | + assert.equal(duplicateResult.favoriteId, "http-favorite-b"); | |
| 788 | + assert.equal(counts.catalog, 1); | |
| 789 | + assert.equal(counts.relay, 1); | |
| 790 | + const oversizedResponse = await rawPersistentRequest( | |
| 791 | + address.port, | |
| 792 | + `POST ${DEPARTURE_ROUTE} HTTP/1.1\r\n` | |
| 793 | + + `Host: 127.0.0.1:${address.port}\r\n` | |
| 794 | + + `Authorization: ${AUTHORIZATION_A}\r\n` | |
| 795 | + + `Content-Length: ${DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes + 1}\r\n` | |
| 796 | + + "Expect: 100-continue\r\n" | |
| 797 | + + "Connection: keep-alive\r\n\r\n", | |
| 798 | + ); | |
| 799 | + assert.match(oversizedResponse, /^HTTP\/1\.1 400 Bad Request\r\n/u); | |
| 800 | + assert.equal(oversizedResponse.includes("100 Continue"), false); | |
| 801 | + assert.match(oversizedResponse.toLowerCase(), /\r\nconnection: close\r\n/u); | |
| 802 | + assert.match(oversizedResponse, /"code":"INVALID_SERVICE"/u); | |
| 803 | + assert.equal(counts.catalog, 1); | |
| 804 | + assert.equal(counts.relay, 1); | |
| 805 | + } finally { | |
| 806 | + const closed = once(server, "close"); | |
| 807 | + server.close(); | |
| 808 | + await closed; | |
| 809 | + } | |
| 810 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,810 @@ | |||
| 1 | +import assert from "node:assert/strict"; | ||
| 2 | +import { once } from "node:events"; | ||
| 3 | +import { readFile } from "node:fs/promises"; | ||
| 4 | +import { request as httpRequest } from "node:http"; | ||
| 5 | +import { createConnection } from "node:net"; | ||
| 6 | +import test from "node:test"; | ||
| 7 | +import { | ||
| 8 | + LIMITS, | ||
| 9 | + PRIM_ORIGIN, | ||
| 10 | + SCHEMA_VERSION, | ||
| 11 | + isDepartureResult, | ||
| 12 | + isErrorResult, | ||
| 13 | + type DepartureResult, | ||
| 14 | + type ErrorResult, | ||
| 15 | +} from "../../contracts/src/index.ts"; | ||
| 16 | +import { | ||
| 17 | + DEPARTURE_ENDPOINT_LIMITS, | ||
| 18 | + DEPARTURE_METRIC_NAME, | ||
| 19 | + DEPARTURE_ROUTE, | ||
| 20 | + createDepartureEndpoint, | ||
| 21 | + createDepartureHttpServer, | ||
| 22 | + type CatalogReader, | ||
| 23 | + type CatalogServiceResolution, | ||
| 24 | + type DepartureEndpoint, | ||
| 25 | + type DepartureEndpointBody, | ||
| 26 | + type DepartureEndpointRequest, | ||
| 27 | + type DepartureEndpointResponse, | ||
| 28 | + type DepartureMetricName, | ||
| 29 | + type DepartureMetrics, | ||
| 30 | + type PrimFetch, | ||
| 31 | + type PrimFetchInit, | ||
| 32 | + type PrimFetchResponse, | ||
| 33 | + type RelayTimer, | ||
| 34 | + type UpstreamLimiter, | ||
| 35 | +} from "../src/index.ts"; | ||
| 36 | + | ||
| 37 | +const encoder = new TextEncoder(); | ||
| 38 | +const decoder = new TextDecoder("utf-8", { fatal: true }); | ||
| 39 | +const CLOCK_MS = Date.parse("2026-01-15T08:30:00Z"); | ||
| 40 | +const CLOCK_SECONDS = CLOCK_MS / 1_000; | ||
| 41 | +const SERVICE_ID = "svc-bus"; | ||
| 42 | +const KEY_A = "endpoint-personal-key-a"; | ||
| 43 | +const KEY_B = "endpoint-personal-key-b"; | ||
| 44 | +const AUTHORIZATION_A = `Bearer ${KEY_A}`; | ||
| 45 | +const AUTHORIZATION_B = `Bearer ${KEY_B}`; | ||
| 46 | +const BUS_FIXTURE = readFile( | ||
| 47 | + new URL("../../../fixtures/departures/prim/bus.json", import.meta.url), | ||
| 48 | +); | ||
| 49 | + | ||
| 50 | +const RESOLUTION: CatalogServiceResolution = { | ||
| 51 | + status: "RESOLVED", | ||
| 52 | + monitoringRef: "IDFM:SP:1001", | ||
| 53 | + lineRef: "IDFM:C1001", | ||
| 54 | + directionId: "IDFM:1001D", | ||
| 55 | + destinationRef: "IDFM:1001DST", | ||
| 56 | + destinationLabel: "Fixture destination", | ||
| 57 | +}; | ||
| 58 | + | ||
| 59 | +interface CallCounts { | ||
| 60 | + catalog: number; | ||
| 61 | + relay: number; | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +interface RecordedMetric { | ||
| 65 | + readonly name: DepartureMetricName; | ||
| 66 | + readonly value: number; | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +function epochSeconds(value: string): number { | ||
| 70 | + return Date.parse(value) / 1_000; | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +function catalogFixture(counts: CallCounts): CatalogReader { | ||
| 74 | + return { | ||
| 75 | + searchPlaces: () => [], | ||
| 76 | + listServices: () => undefined, | ||
| 77 | + resolveService(serviceId) { | ||
| 78 | + counts.catalog += 1; | ||
| 79 | + return serviceId === SERVICE_ID | ||
| 80 | + ? RESOLUTION | ||
| 81 | + : { status: "UNRESOLVED", code: "INVALID_SERVICE" }; | ||
| 82 | + }, | ||
| 83 | + }; | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +function metricsRecorder(): DepartureMetrics & { readonly entries: RecordedMetric[] } { | ||
| 87 | + const entries: RecordedMetric[] = []; | ||
| 88 | + return { | ||
| 89 | + entries, | ||
| 90 | + record(name, value) { | ||
| 91 | + entries.push({ name, value }); | ||
| 92 | + }, | ||
| 93 | + }; | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +function primResponse( | ||
| 97 | + chunks: readonly Uint8Array[], | ||
| 98 | + status = 200, | ||
| 99 | + retryAfter?: string, | ||
| 100 | +): PrimFetchResponse { | ||
| 101 | + return { | ||
| 102 | + status, | ||
| 103 | + headers: { | ||
| 104 | + get(name) { | ||
| 105 | + return name.toLowerCase() === "retry-after" && retryAfter !== undefined | ||
| 106 | + ? retryAfter | ||
| 107 | + : null; | ||
| 108 | + }, | ||
| 109 | + }, | ||
| 110 | + body: new ReadableStream<Uint8Array>({ | ||
| 111 | + start(controller) { | ||
| 112 | + for (const chunk of chunks) controller.enqueue(chunk); | ||
| 113 | + controller.close(); | ||
| 114 | + }, | ||
| 115 | + }), | ||
| 116 | + }; | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +function requestBytes( | ||
| 120 | + requestId = "request-a", | ||
| 121 | + favoriteId = "favorite-a", | ||
| 122 | + serviceId = SERVICE_ID, | ||
| 123 | +): Uint8Array { | ||
| 124 | + return encoder.encode(JSON.stringify({ | ||
| 125 | + schemaVersion: SCHEMA_VERSION, | ||
| 126 | + requestId, | ||
| 127 | + favoriteId, | ||
| 128 | + serviceId, | ||
| 129 | + })); | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +function endpointRequest( | ||
| 133 | + body: DepartureEndpointBody, | ||
| 134 | + authorization = AUTHORIZATION_A, | ||
| 135 | + headers: DepartureEndpointRequest["headers"] = [["Authorization", authorization]], | ||
| 136 | +): DepartureEndpointRequest { | ||
| 137 | + return { | ||
| 138 | + method: "POST", | ||
| 139 | + path: DEPARTURE_ROUTE, | ||
| 140 | + headers, | ||
| 141 | + body, | ||
| 142 | + }; | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +function decodeResponse(response: DepartureEndpointResponse): DepartureResult | ErrorResult { | ||
| 146 | + assert.equal(response.headers["content-type"], "application/json"); | ||
| 147 | + assert.ok(response.body instanceof Uint8Array); | ||
| 148 | + const parsed: unknown = JSON.parse(decoder.decode(response.body)); | ||
| 149 | + assert.ok(isDepartureResult(parsed) || isErrorResult(parsed)); | ||
| 150 | + return parsed; | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +function expectDeparture(response: DepartureEndpointResponse): DepartureResult { | ||
| 154 | + const parsed = decodeResponse(response); | ||
| 155 | + assert.equal(isDepartureResult(parsed), true); | ||
| 156 | + if (!isDepartureResult(parsed)) assert.fail("expected a departure result"); | ||
| 157 | + return parsed; | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +function expectError(response: DepartureEndpointResponse): ErrorResult { | ||
| 161 | + const parsed = decodeResponse(response); | ||
| 162 | + assert.equal(isErrorResult(parsed), true); | ||
| 163 | + if (!isErrorResult(parsed)) assert.fail("expected an error result"); | ||
| 164 | + return parsed; | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +function rawReadable(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> { | ||
| 168 | + return new ReadableStream<Uint8Array>({ | ||
| 169 | + start(controller) { | ||
| 170 | + for (const chunk of chunks) controller.enqueue(chunk); | ||
| 171 | + controller.close(); | ||
| 172 | + }, | ||
| 173 | + }); | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +function rawAsyncIterable( | ||
| 177 | + chunks: readonly Uint8Array[], | ||
| 178 | + closed?: () => void, | ||
| 179 | +): AsyncIterable<Uint8Array> { | ||
| 180 | + return { | ||
| 181 | + [Symbol.asyncIterator]() { | ||
| 182 | + let index = 0; | ||
| 183 | + return { | ||
| 184 | + async next() { | ||
| 185 | + const value = chunks[index]; | ||
| 186 | + index += 1; | ||
| 187 | + return value === undefined | ||
| 188 | + ? { done: true as const, value: undefined } | ||
| 189 | + : { done: false as const, value }; | ||
| 190 | + }, | ||
| 191 | + async return() { | ||
| 192 | + closed?.(); | ||
| 193 | + return { done: true as const, value: undefined }; | ||
| 194 | + }, | ||
| 195 | + }; | ||
| 196 | + }, | ||
| 197 | + }; | ||
| 198 | +} | ||
| 199 | + | ||
| 200 | +function successfulEndpoint( | ||
| 201 | + fixture: Uint8Array, | ||
| 202 | + counts: CallCounts, | ||
| 203 | +): DepartureEndpoint { | ||
| 204 | + const fetch: PrimFetch = async () => { | ||
| 205 | + counts.relay += 1; | ||
| 206 | + return primResponse([fixture]); | ||
| 207 | + }; | ||
| 208 | + return createDepartureEndpoint({ | ||
| 209 | + catalog: catalogFixture(counts), | ||
| 210 | + fetch, | ||
| 211 | + clock: () => CLOCK_MS, | ||
| 212 | + }); | ||
| 213 | +} | ||
| 214 | + | ||
| 215 | +function totalHeaderBytes(headers: DepartureEndpointRequest["headers"]): number { | ||
| 216 | + return headers.reduce( | ||
| 217 | + (total, [name, value]) => | ||
| 218 | + total + encoder.encode(name).byteLength + 2 + encoder.encode(value).byteLength + 2, | ||
| 219 | + 2, | ||
| 220 | + ); | ||
| 221 | +} | ||
| 222 | + | ||
| 223 | +interface HttpResponseCapture { | ||
| 224 | + readonly status: number; | ||
| 225 | + readonly contentType: string | string[] | undefined; | ||
| 226 | + readonly body: Uint8Array; | ||
| 227 | +} | ||
| 228 | + | ||
| 229 | +function postOverHttp( | ||
| 230 | + port: number, | ||
| 231 | + body: Uint8Array, | ||
| 232 | + headers: Readonly<Record<string, string | string[]>>, | ||
| 233 | +): Promise<HttpResponseCapture> { | ||
| 234 | + const { promise, resolve, reject } = Promise.withResolvers<HttpResponseCapture>(); | ||
| 235 | + const request = httpRequest({ | ||
| 236 | + host: "127.0.0.1", | ||
| 237 | + port, | ||
| 238 | + method: "POST", | ||
| 239 | + path: DEPARTURE_ROUTE, | ||
| 240 | + headers, | ||
| 241 | + }, (response) => { | ||
| 242 | + const chunks: Uint8Array[] = []; | ||
| 243 | + let byteLength = 0; | ||
| 244 | + response.on("data", (chunk: Uint8Array) => { | ||
| 245 | + chunks.push(chunk); | ||
| 246 | + byteLength += chunk.byteLength; | ||
| 247 | + }); | ||
| 248 | + response.once("error", reject); | ||
| 249 | + response.once("end", () => { | ||
| 250 | + const bytes = new Uint8Array(byteLength); | ||
| 251 | + let offset = 0; | ||
| 252 | + for (const chunk of chunks) { | ||
| 253 | + bytes.set(chunk, offset); | ||
| 254 | + offset += chunk.byteLength; | ||
| 255 | + } | ||
| 256 | + resolve({ | ||
| 257 | + status: response.statusCode ?? 0, | ||
| 258 | + contentType: response.headers["content-type"], | ||
| 259 | + body: bytes, | ||
| 260 | + }); | ||
| 261 | + }); | ||
| 262 | + }); | ||
| 263 | + request.once("error", reject); | ||
| 264 | + request.end(body); | ||
| 265 | + return promise; | ||
| 266 | +} | ||
| 267 | + | ||
| 268 | +function rawPersistentRequest(port: number, requestText: string): Promise<string> { | ||
| 269 | + const { promise, resolve, reject } = Promise.withResolvers<string>(); | ||
| 270 | + const socket = createConnection({ host: "127.0.0.1", port }); | ||
| 271 | + let responseText = ""; | ||
| 272 | + let settled = false; | ||
| 273 | + const fail = (error: Error) => { | ||
| 274 | + if (settled) return; | ||
| 275 | + settled = true; | ||
| 276 | + socket.destroy(); | ||
| 277 | + reject(error); | ||
| 278 | + }; | ||
| 279 | + | ||
| 280 | + socket.setEncoding("latin1"); | ||
| 281 | + // A protocol regression leaves this real socket open, so an event alone cannot bound the test. | ||
| 282 | + socket.setTimeout(1_000, () => { | ||
| 283 | + fail(new Error("raw HTTP connection did not close")); | ||
| 284 | + }); | ||
| 285 | + socket.on("data", (chunk: string) => { | ||
| 286 | + responseText += chunk; | ||
| 287 | + }); | ||
| 288 | + socket.once("connect", () => { | ||
| 289 | + socket.write(requestText); | ||
| 290 | + }); | ||
| 291 | + socket.once("error", fail); | ||
| 292 | + socket.once("close", () => { | ||
| 293 | + if (settled) return; | ||
| 294 | + settled = true; | ||
| 295 | + resolve(responseText); | ||
| 296 | + }); | ||
| 297 | + return promise; | ||
| 298 | +} | ||
| 299 | + | ||
| 300 | +test("production endpoint composes catalog, bounded relay, normalization, binding, and shared cache", async () => { | ||
| 301 | + const fixture = new Uint8Array(await BUS_FIXTURE); | ||
| 302 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 303 | + const metrics = metricsRecorder(); | ||
| 304 | + const captures: Array<{ readonly target: URL; readonly init: PrimFetchInit }> = []; | ||
| 305 | + const logs: string[] = []; | ||
| 306 | + let timerSets = 0; | ||
| 307 | + let timerClears = 0; | ||
| 308 | + let limiterRuns = 0; | ||
| 309 | + const timerHandle = Object.freeze({ timer: "endpoint" }); | ||
| 310 | + const timer: RelayTimer = { | ||
| 311 | + set(_callback, delayMs) { | ||
| 312 | + assert.equal(delayMs, LIMITS.httpTimeoutMs); | ||
| 313 | + timerSets += 1; | ||
| 314 | + return timerHandle; | ||
| 315 | + }, | ||
| 316 | + clear(handle) { | ||
| 317 | + assert.equal(handle, timerHandle); | ||
| 318 | + timerClears += 1; | ||
| 319 | + }, | ||
| 320 | + }; | ||
| 321 | + const limiter: UpstreamLimiter = { | ||
| 322 | + async run<T>(operation: () => Promise<T>): Promise<T> { | ||
| 323 | + limiterRuns += 1; | ||
| 324 | + return operation(); | ||
| 325 | + }, | ||
| 326 | + }; | ||
| 327 | + const fetch: PrimFetch = async (target, init) => { | ||
| 328 | + counts.relay += 1; | ||
| 329 | + captures.push({ target, init }); | ||
| 330 | + const midpoint = Math.floor(fixture.byteLength / 2); | ||
| 331 | + return primResponse([fixture.subarray(0, midpoint), fixture.subarray(midpoint)]); | ||
| 332 | + }; | ||
| 333 | + const endpoint = createDepartureEndpoint({ | ||
| 334 | + catalog: catalogFixture(counts), | ||
| 335 | + fetch, | ||
| 336 | + metrics, | ||
| 337 | + clock: () => CLOCK_MS, | ||
| 338 | + timer, | ||
| 339 | + limiter, | ||
| 340 | + logger(message) { | ||
| 341 | + logs.push(message); | ||
| 342 | + }, | ||
| 343 | + }); | ||
| 344 | + | ||
| 345 | + const firstBytes = requestBytes(); | ||
| 346 | + const midpoint = Math.floor(firstBytes.byteLength / 2); | ||
| 347 | + const firstResponse = await endpoint(endpointRequest(rawReadable([ | ||
| 348 | + firstBytes.subarray(0, midpoint), | ||
| 349 | + firstBytes.subarray(midpoint), | ||
| 350 | + ]))); | ||
| 351 | + assert.equal(firstResponse.status, 200); | ||
| 352 | + assert.deepEqual(expectDeparture(firstResponse), { | ||
| 353 | + schemaVersion: SCHEMA_VERSION, | ||
| 354 | + requestId: "request-a", | ||
| 355 | + favoriteId: "favorite-a", | ||
| 356 | + fetchedAt: CLOCK_SECONDS, | ||
| 357 | + sourceUpdatedAt: CLOCK_SECONDS, | ||
| 358 | + freshness: "REALTIME", | ||
| 359 | + departures: [ | ||
| 360 | + { | ||
| 361 | + expectedAt: epochSeconds("2026-01-15T08:32:00Z"), | ||
| 362 | + aimedAt: epochSeconds("2026-01-15T08:31:00Z"), | ||
| 363 | + minutes: 2, | ||
| 364 | + status: "ON_TIME", | ||
| 365 | + nextIntervalMinutes: 6, | ||
| 366 | + }, | ||
| 367 | + { | ||
| 368 | + expectedAt: epochSeconds("2026-01-15T08:38:00Z"), | ||
| 369 | + aimedAt: epochSeconds("2026-01-15T08:37:30Z"), | ||
| 370 | + minutes: 8, | ||
| 371 | + status: "ON_TIME", | ||
| 372 | + }, | ||
| 373 | + ], | ||
| 374 | + }); | ||
| 375 | + | ||
| 376 | + assert.equal(counts.catalog, 1); | ||
| 377 | + assert.equal(counts.relay, 1); | ||
| 378 | + assert.equal(limiterRuns, 1); | ||
| 379 | + assert.equal(timerSets, 1); | ||
| 380 | + assert.equal(timerClears, 1); | ||
| 381 | + assert.deepEqual(logs, []); | ||
| 382 | + assert.equal(captures.length, 1); | ||
| 383 | + const firstRelay = captures[0]!; | ||
| 384 | + assert.equal(firstRelay.target.origin, PRIM_ORIGIN); | ||
| 385 | + assert.equal(firstRelay.target.hostname, "prim.iledefrance-mobilites.fr"); | ||
| 386 | + assert.equal(firstRelay.target.pathname, "/marketplace/stop-monitoring"); | ||
| 387 | + assert.deepEqual([...firstRelay.target.searchParams], [ | ||
| 388 | + ["MonitoringRef", RESOLUTION.monitoringRef], | ||
| 389 | + ["LineRef", RESOLUTION.lineRef], | ||
| 390 | + ]); | ||
| 391 | + assert.equal(firstRelay.target.href.includes(KEY_A), false); | ||
| 392 | + assert.equal(firstRelay.init.method, "GET"); | ||
| 393 | + assert.deepEqual(firstRelay.init.headers, { | ||
| 394 | + apikey: KEY_A, | ||
| 395 | + accept: "application/json", | ||
| 396 | + "accept-encoding": "gzip", | ||
| 397 | + }); | ||
| 398 | + assert.deepEqual(Object.keys(firstRelay.init.headers), ["apikey", "accept", "accept-encoding"]); | ||
| 399 | + assert.equal(firstRelay.init.redirect, "manual"); | ||
| 400 | + assert.equal(firstRelay.init.credentials, "omit"); | ||
| 401 | + assert.equal(firstRelay.init.cache, "no-store"); | ||
| 402 | + | ||
| 403 | + const secondResponse = await endpoint(endpointRequest( | ||
| 404 | + rawAsyncIterable([requestBytes("request-b", "favorite-b")]), | ||
| 405 | + AUTHORIZATION_B, | ||
| 406 | + [["aUtHoRiZaTiOn", AUTHORIZATION_B]], | ||
| 407 | + )); | ||
| 408 | + assert.equal(secondResponse.status, 200); | ||
| 409 | + const second = expectDeparture(secondResponse); | ||
| 410 | + assert.equal(second.requestId, "request-b"); | ||
| 411 | + assert.equal(second.favoriteId, "favorite-b"); | ||
| 412 | + assert.equal(counts.catalog, 1); | ||
| 413 | + assert.equal(counts.relay, 1); | ||
| 414 | + assert.equal(limiterRuns, 1); | ||
| 415 | + assert.equal(timerSets, 1); | ||
| 416 | + assert.equal(timerClears, 1); | ||
| 417 | + | ||
| 418 | + const metricNames = metrics.entries.map(({ name }) => name); | ||
| 419 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.CACHE_MISS)); | ||
| 420 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.CACHE_HIT)); | ||
| 421 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST)); | ||
| 422 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.UPSTREAM_STATUS)); | ||
| 423 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS)); | ||
| 424 | + assert.ok(metricNames.includes(DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES)); | ||
| 425 | + assert.deepEqual( | ||
| 426 | + metrics.entries | ||
| 427 | + .filter(({ name }) => name === DEPARTURE_METRIC_NAME.UPSTREAM_STATUS) | ||
| 428 | + .map(({ value }) => value), | ||
| 429 | + [200], | ||
| 430 | + ); | ||
| 431 | + | ||
| 432 | + for (const secret of [KEY_A, KEY_B, AUTHORIZATION_A, AUTHORIZATION_B]) { | ||
| 433 | + assert.equal(decoder.decode(firstResponse.body).includes(secret), false); | ||
| 434 | + assert.equal(decoder.decode(secondResponse.body).includes(secret), false); | ||
| 435 | + assert.equal(JSON.stringify(metrics.entries).includes(secret), false); | ||
| 436 | + assert.equal(JSON.stringify(logs).includes(secret), false); | ||
| 437 | + } | ||
| 438 | + assert.deepEqual(Object.keys(endpoint), []); | ||
| 439 | +}); | ||
| 440 | + | ||
| 441 | +test("endpoint maps actual PRIM 401, 403, and 429 responses through JSON errors", async () => { | ||
| 442 | + const fixture = new Uint8Array(await BUS_FIXTURE); | ||
| 443 | + for (const status of [401, 403, 429] as const) { | ||
| 444 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 445 | + const metrics = metricsRecorder(); | ||
| 446 | + const fetch: PrimFetch = async () => { | ||
| 447 | + counts.relay += 1; | ||
| 448 | + return primResponse([fixture], status, status === 429 ? "37" : undefined); | ||
| 449 | + }; | ||
| 450 | + const endpoint = createDepartureEndpoint({ | ||
| 451 | + catalog: catalogFixture(counts), | ||
| 452 | + fetch, | ||
| 453 | + metrics, | ||
| 454 | + clock: () => CLOCK_MS, | ||
| 455 | + }); | ||
| 456 | + | ||
| 457 | + const response = await endpoint(endpointRequest(requestBytes())); | ||
| 458 | + const error = expectError(response); | ||
| 459 | + assert.equal(response.status, status === 429 ? 429 : 401); | ||
| 460 | + assert.equal(error.code, status === 429 ? "RATE_LIMITED" : "API_KEY_INVALID"); | ||
| 461 | + assert.equal(error.requestId, "request-a"); | ||
| 462 | + assert.equal(error.favoriteId, "favorite-a"); | ||
| 463 | + assert.equal(error.occurredAt, CLOCK_SECONDS); | ||
| 464 | + assert.equal(error.retryAfterSeconds, status === 429 ? 37 : undefined); | ||
| 465 | + assert.equal(counts.catalog, 1); | ||
| 466 | + assert.equal(counts.relay, 1); | ||
| 467 | + assert.deepEqual( | ||
| 468 | + metrics.entries | ||
| 469 | + .filter(({ name }) => name === DEPARTURE_METRIC_NAME.UPSTREAM_STATUS) | ||
| 470 | + .map(({ value }) => value), | ||
| 471 | + [status], | ||
| 472 | + ); | ||
| 473 | + assert.equal(decoder.decode(response.body).includes(KEY_A), false); | ||
| 474 | + assert.equal(JSON.stringify(metrics.entries).includes(KEY_A), false); | ||
| 475 | + } | ||
| 476 | +}); | ||
| 477 | + | ||
| 478 | +test("request body and total headers accept the exact byte limit and reject one byte more", async () => { | ||
| 479 | + const fixture = new Uint8Array(await BUS_FIXTURE); | ||
| 480 | + const compact = requestBytes(); | ||
| 481 | + const bodyPadding = DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes - compact.byteLength; | ||
| 482 | + assert.ok(bodyPadding >= 0); | ||
| 483 | + const exactBody = encoder.encode(`${decoder.decode(compact)}${" ".repeat(bodyPadding)}`); | ||
| 484 | + assert.equal(exactBody.byteLength, DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes); | ||
| 485 | + | ||
| 486 | + const exactBodyCounts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 487 | + const exactBodyEndpoint = successfulEndpoint(fixture, exactBodyCounts); | ||
| 488 | + const exactBodyResponse = await exactBodyEndpoint(endpointRequest( | ||
| 489 | + exactBody, | ||
| 490 | + AUTHORIZATION_A, | ||
| 491 | + [ | ||
| 492 | + ["Authorization", AUTHORIZATION_A], | ||
| 493 | + ["Content-Length", String(exactBody.byteLength)], | ||
| 494 | + ], | ||
| 495 | + )); | ||
| 496 | + assert.equal(exactBodyResponse.status, 200); | ||
| 497 | + assert.equal(exactBodyCounts.catalog, 1); | ||
| 498 | + assert.equal(exactBodyCounts.relay, 1); | ||
| 499 | + | ||
| 500 | + const oversizedBodyCounts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 501 | + const oversizedBodyEndpoint = successfulEndpoint(fixture, oversizedBodyCounts); | ||
| 502 | + const oversizedBody = new Uint8Array(DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes + 1); | ||
| 503 | + const oversizedBodyResponse = await oversizedBodyEndpoint(endpointRequest(oversizedBody)); | ||
| 504 | + assert.equal(oversizedBodyResponse.status, 400); | ||
| 505 | + assert.equal(expectError(oversizedBodyResponse).code, "INVALID_SERVICE"); | ||
| 506 | + assert.deepEqual(oversizedBodyCounts, { catalog: 0, relay: 0 }); | ||
| 507 | + | ||
| 508 | + const baseHeaders = [["Authorization", AUTHORIZATION_A]] as const; | ||
| 509 | + const paddingName = "X-Padding"; | ||
| 510 | + const paddingLength = DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes | ||
| 511 | + - totalHeaderBytes([...baseHeaders, [paddingName, ""]]); | ||
| 512 | + assert.ok(paddingLength >= 0); | ||
| 513 | + const exactHeaders = [ | ||
| 514 | + ...baseHeaders, | ||
| 515 | + [paddingName, "x".repeat(paddingLength)] as const, | ||
| 516 | + ]; | ||
| 517 | + assert.equal(totalHeaderBytes(exactHeaders), DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes); | ||
| 518 | + | ||
| 519 | + const exactHeaderCounts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 520 | + const exactHeaderEndpoint = successfulEndpoint(fixture, exactHeaderCounts); | ||
| 521 | + const exactHeaderResponse = await exactHeaderEndpoint(endpointRequest( | ||
| 522 | + compact, | ||
| 523 | + AUTHORIZATION_A, | ||
| 524 | + exactHeaders, | ||
| 525 | + )); | ||
| 526 | + assert.equal(exactHeaderResponse.status, 200); | ||
| 527 | + assert.deepEqual(exactHeaderCounts, { catalog: 1, relay: 1 }); | ||
| 528 | + | ||
| 529 | + const oversizedHeaderCounts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 530 | + const oversizedHeaderEndpoint = successfulEndpoint(fixture, oversizedHeaderCounts); | ||
| 531 | + const oversizedHeaders = [ | ||
| 532 | + ...baseHeaders, | ||
| 533 | + [paddingName, "x".repeat(paddingLength + 1)] as const, | ||
| 534 | + ]; | ||
| 535 | + assert.equal( | ||
| 536 | + totalHeaderBytes(oversizedHeaders), | ||
| 537 | + DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes + 1, | ||
| 538 | + ); | ||
| 539 | + const oversizedHeaderResponse = await oversizedHeaderEndpoint(endpointRequest( | ||
| 540 | + compact, | ||
| 541 | + AUTHORIZATION_A, | ||
| 542 | + oversizedHeaders, | ||
| 543 | + )); | ||
| 544 | + assert.equal(oversizedHeaderResponse.status, 400); | ||
| 545 | + assert.equal(expectError(oversizedHeaderResponse).requestId, "invalid-request"); | ||
| 546 | + assert.deepEqual(oversizedHeaderCounts, { catalog: 0, relay: 0 }); | ||
| 547 | + | ||
| 548 | + const manySmallHeaderCounts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 549 | + const manySmallHeaders = [ | ||
| 550 | + ...baseHeaders, | ||
| 551 | + ...Array.from( | ||
| 552 | + { length: 300 }, | ||
| 553 | + (_, index) => [`X-${index % 10}`, ""] as const, | ||
| 554 | + ), | ||
| 555 | + ]; | ||
| 556 | + const nameAndValueBytes = manySmallHeaders.reduce( | ||
| 557 | + (total, [name, value]) => | ||
| 558 | + total + encoder.encode(name).byteLength + encoder.encode(value).byteLength, | ||
| 559 | + 0, | ||
| 560 | + ); | ||
| 561 | + assert.ok(nameAndValueBytes < DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes); | ||
| 562 | + assert.ok(totalHeaderBytes(manySmallHeaders) > DEPARTURE_ENDPOINT_LIMITS.totalHeaderBytes); | ||
| 563 | + const manySmallHeaderResponse = await successfulEndpoint( | ||
| 564 | + fixture, | ||
| 565 | + manySmallHeaderCounts, | ||
| 566 | + )(endpointRequest(compact, AUTHORIZATION_A, manySmallHeaders)); | ||
| 567 | + assert.equal(manySmallHeaderResponse.status, 400); | ||
| 568 | + assert.deepEqual(manySmallHeaderCounts, { catalog: 0, relay: 0 }); | ||
| 569 | +}); | ||
| 570 | + | ||
| 571 | +test("Content-Length is strict, bounded before body access, and must match actual bytes", async () => { | ||
| 572 | + const fixture = new Uint8Array(await BUS_FIXTURE); | ||
| 573 | + const body = requestBytes(); | ||
| 574 | + | ||
| 575 | + for (const headers of [ | ||
| 576 | + [["Authorization", AUTHORIZATION_A], ["Content-Length", "12x"]], | ||
| 577 | + [["Authorization", AUTHORIZATION_A], ["Content-Length", "1"], ["content-length", "1"]], | ||
| 578 | + ] as const) { | ||
| 579 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 580 | + let iterated = false; | ||
| 581 | + const lazyBody: AsyncIterable<Uint8Array> = { | ||
| 582 | + [Symbol.asyncIterator]() { | ||
| 583 | + iterated = true; | ||
| 584 | + return rawAsyncIterable([body])[Symbol.asyncIterator](); | ||
| 585 | + }, | ||
| 586 | + }; | ||
| 587 | + const response = await successfulEndpoint(fixture, counts)(endpointRequest( | ||
| 588 | + lazyBody, | ||
| 589 | + AUTHORIZATION_A, | ||
| 590 | + headers, | ||
| 591 | + )); | ||
| 592 | + assert.equal(response.status, 400); | ||
| 593 | + assert.equal(iterated, false); | ||
| 594 | + assert.deepEqual(counts, { catalog: 0, relay: 0 }); | ||
| 595 | + } | ||
| 596 | + | ||
| 597 | + const overLimitCounts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 598 | + let overLimitIterated = false; | ||
| 599 | + const overLimitBody: AsyncIterable<Uint8Array> = { | ||
| 600 | + [Symbol.asyncIterator]() { | ||
| 601 | + overLimitIterated = true; | ||
| 602 | + return rawAsyncIterable([body])[Symbol.asyncIterator](); | ||
| 603 | + }, | ||
| 604 | + }; | ||
| 605 | + const overLimitResponse = await successfulEndpoint(fixture, overLimitCounts)(endpointRequest( | ||
| 606 | + overLimitBody, | ||
| 607 | + AUTHORIZATION_A, | ||
| 608 | + [ | ||
| 609 | + ["Authorization", AUTHORIZATION_A], | ||
| 610 | + ["Content-Length", String(DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes + 1)], | ||
| 611 | + ], | ||
| 612 | + )); | ||
| 613 | + assert.equal(overLimitResponse.status, 400); | ||
| 614 | + assert.equal(overLimitIterated, false); | ||
| 615 | + assert.deepEqual(overLimitCounts, { catalog: 0, relay: 0 }); | ||
| 616 | + | ||
| 617 | + for (const declared of [body.byteLength - 1, body.byteLength + 1]) { | ||
| 618 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 619 | + const response = await successfulEndpoint(fixture, counts)(endpointRequest( | ||
| 620 | + body, | ||
| 621 | + AUTHORIZATION_A, | ||
| 622 | + [ | ||
| 623 | + ["Authorization", AUTHORIZATION_A], | ||
| 624 | + ["Content-Length", String(declared)], | ||
| 625 | + ], | ||
| 626 | + )); | ||
| 627 | + assert.equal(response.status, 400); | ||
| 628 | + assert.deepEqual(counts, { catalog: 0, relay: 0 }); | ||
| 629 | + } | ||
| 630 | +}); | ||
| 631 | + | ||
| 632 | +test("overflowing streamed bodies are cancelled before JSON parsing or resolution", async () => { | ||
| 633 | + const fixture = new Uint8Array(await BUS_FIXTURE); | ||
| 634 | + const fullChunk = new Uint8Array(DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes); | ||
| 635 | + | ||
| 636 | + const streamCounts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 637 | + let streamCancelled = false; | ||
| 638 | + const overflowingStream = new ReadableStream<Uint8Array>({ | ||
| 639 | + start(controller) { | ||
| 640 | + controller.enqueue(fullChunk); | ||
| 641 | + controller.enqueue(Uint8Array.of(1)); | ||
| 642 | + }, | ||
| 643 | + cancel() { | ||
| 644 | + streamCancelled = true; | ||
| 645 | + }, | ||
| 646 | + }); | ||
| 647 | + const streamResponse = await successfulEndpoint(fixture, streamCounts)( | ||
| 648 | + endpointRequest(overflowingStream), | ||
| 649 | + ); | ||
| 650 | + assert.equal(streamResponse.status, 400); | ||
| 651 | + assert.equal(streamCancelled, true); | ||
| 652 | + assert.deepEqual(streamCounts, { catalog: 0, relay: 0 }); | ||
| 653 | + | ||
| 654 | + const iterableCounts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 655 | + let iteratorClosed = false; | ||
| 656 | + const iterableResponse = await successfulEndpoint(fixture, iterableCounts)(endpointRequest( | ||
| 657 | + rawAsyncIterable([fullChunk, Uint8Array.of(1)], () => { | ||
| 658 | + iteratorClosed = true; | ||
| 659 | + }), | ||
| 660 | + )); | ||
| 661 | + assert.equal(iterableResponse.status, 400); | ||
| 662 | + assert.equal(iteratorClosed, true); | ||
| 663 | + assert.deepEqual(iterableCounts, { catalog: 0, relay: 0 }); | ||
| 664 | +}); | ||
| 665 | + | ||
| 666 | +test("fatal UTF-8, JSON syntax, and duplicate or malformed Authorization reject without resolution", async () => { | ||
| 667 | + const fixture = new Uint8Array(await BUS_FIXTURE); | ||
| 668 | + const cases: ReadonlyArray<{ | ||
| 669 | + readonly body: DepartureEndpointBody; | ||
| 670 | + readonly headers: DepartureEndpointRequest["headers"]; | ||
| 671 | + readonly status: 400 | 401; | ||
| 672 | + readonly code: "INVALID_SERVICE" | "API_KEY_INVALID"; | ||
| 673 | + }> = [ | ||
| 674 | + { | ||
| 675 | + body: Uint8Array.of(0xc3, 0x28), | ||
| 676 | + headers: [["Authorization", AUTHORIZATION_A]], | ||
| 677 | + status: 400, | ||
| 678 | + code: "INVALID_SERVICE", | ||
| 679 | + }, | ||
| 680 | + { | ||
| 681 | + body: encoder.encode("{\"schemaVersion\":"), | ||
| 682 | + headers: [["Authorization", AUTHORIZATION_A]], | ||
| 683 | + status: 400, | ||
| 684 | + code: "INVALID_SERVICE", | ||
| 685 | + }, | ||
| 686 | + { | ||
| 687 | + body: requestBytes(), | ||
| 688 | + headers: [ | ||
| 689 | + ["Authorization", AUTHORIZATION_A], | ||
| 690 | + ["authorization", AUTHORIZATION_A], | ||
| 691 | + ], | ||
| 692 | + status: 401, | ||
| 693 | + code: "API_KEY_INVALID", | ||
| 694 | + }, | ||
| 695 | + { | ||
| 696 | + body: requestBytes(), | ||
| 697 | + headers: [["Authorization", `bearer ${KEY_A}`]], | ||
| 698 | + status: 401, | ||
| 699 | + code: "API_KEY_INVALID", | ||
| 700 | + }, | ||
| 701 | + ]; | ||
| 702 | + | ||
| 703 | + for (const fixtureCase of cases) { | ||
| 704 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 705 | + const response = await successfulEndpoint(fixture, counts)({ | ||
| 706 | + method: "POST", | ||
| 707 | + path: DEPARTURE_ROUTE, | ||
| 708 | + headers: fixtureCase.headers, | ||
| 709 | + body: fixtureCase.body, | ||
| 710 | + }); | ||
| 711 | + assert.equal(response.status, fixtureCase.status); | ||
| 712 | + assert.equal(expectError(response).code, fixtureCase.code); | ||
| 713 | + assert.deepEqual(counts, { catalog: 0, relay: 0 }); | ||
| 714 | + } | ||
| 715 | +}); | ||
| 716 | + | ||
| 717 | +test("endpoint preserves the domain handler's exact path and POST status semantics", async () => { | ||
| 718 | + const fixture = new Uint8Array(await BUS_FIXTURE); | ||
| 719 | + const cases = [ | ||
| 720 | + { method: "POST", path: `${DEPARTURE_ROUTE}/`, status: 404 }, | ||
| 721 | + { method: "GET", path: DEPARTURE_ROUTE, status: 405 }, | ||
| 722 | + { method: "post", path: DEPARTURE_ROUTE, status: 405 }, | ||
| 723 | + ] as const; | ||
| 724 | + | ||
| 725 | + for (const fixtureCase of cases) { | ||
| 726 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 727 | + const endpoint = successfulEndpoint(fixture, counts); | ||
| 728 | + const response = await endpoint({ | ||
| 729 | + method: fixtureCase.method, | ||
| 730 | + path: fixtureCase.path, | ||
| 731 | + headers: [["Authorization", AUTHORIZATION_A]], | ||
| 732 | + body: requestBytes(), | ||
| 733 | + }); | ||
| 734 | + assert.equal(response.status, fixtureCase.status); | ||
| 735 | + assert.equal(expectError(response).code, "INVALID_SERVICE"); | ||
| 736 | + assert.deepEqual(counts, { catalog: 0, relay: 0 }); | ||
| 737 | + } | ||
| 738 | +}); | ||
| 739 | + | ||
| 740 | +test("node:http adapter registers the real POST route and preserves duplicate headers", async () => { | ||
| 741 | + const fixture = new Uint8Array(await BUS_FIXTURE); | ||
| 742 | + const counts: CallCounts = { catalog: 0, relay: 0 }; | ||
| 743 | + const fetch: PrimFetch = async () => { | ||
| 744 | + counts.relay += 1; | ||
| 745 | + return primResponse([fixture]); | ||
| 746 | + }; | ||
| 747 | + const server = createDepartureHttpServer({ | ||
| 748 | + catalog: catalogFixture(counts), | ||
| 749 | + fetch, | ||
| 750 | + clock: () => CLOCK_MS, | ||
| 751 | + }); | ||
| 752 | + assert.equal(server.listening, false); | ||
| 753 | + | ||
| 754 | + const listening = once(server, "listening"); | ||
| 755 | + server.listen(0, "127.0.0.1"); | ||
| 756 | + await listening; | ||
| 757 | + try { | ||
| 758 | + const address = server.address(); | ||
| 759 | + assert.ok(address !== null && typeof address === "object"); | ||
| 760 | + const firstBody = requestBytes("http-request-a", "http-favorite-a"); | ||
| 761 | + const first = await postOverHttp(address.port, firstBody, { | ||
| 762 | + Authorization: AUTHORIZATION_A, | ||
| 763 | + "Content-Length": String(firstBody.byteLength), | ||
| 764 | + }); | ||
| 765 | + assert.equal(first.status, 200, decoder.decode(first.body)); | ||
| 766 | + assert.equal(first.contentType, "application/json"); | ||
| 767 | + const firstResult: unknown = JSON.parse(decoder.decode(first.body)); | ||
| 768 | + assert.equal(isDepartureResult(firstResult), true); | ||
| 769 | + if (!isDepartureResult(firstResult)) assert.fail("expected HTTP departure result"); | ||
| 770 | + assert.equal(firstResult.requestId, "http-request-a"); | ||
| 771 | + assert.equal(firstResult.favoriteId, "http-favorite-a"); | ||
| 772 | + assert.equal(counts.catalog, 1); | ||
| 773 | + assert.equal(counts.relay, 1); | ||
| 774 | + | ||
| 775 | + const duplicateBody = requestBytes("http-request-b", "http-favorite-b"); | ||
| 776 | + const duplicate = await postOverHttp(address.port, duplicateBody, { | ||
| 777 | + Authorization: [AUTHORIZATION_B, AUTHORIZATION_B], | ||
| 778 | + "Content-Length": String(duplicateBody.byteLength), | ||
| 779 | + }); | ||
| 780 | + assert.equal(duplicate.status, 401); | ||
| 781 | + assert.equal(duplicate.contentType, "application/json"); | ||
| 782 | + const duplicateResult: unknown = JSON.parse(decoder.decode(duplicate.body)); | ||
| 783 | + assert.equal(isErrorResult(duplicateResult), true); | ||
| 784 | + if (!isErrorResult(duplicateResult)) assert.fail("expected duplicate-header error"); | ||
| 785 | + assert.equal(duplicateResult.code, "API_KEY_INVALID"); | ||
| 786 | + assert.equal(duplicateResult.requestId, "http-request-b"); | ||
| 787 | + assert.equal(duplicateResult.favoriteId, "http-favorite-b"); | ||
| 788 | + assert.equal(counts.catalog, 1); | ||
| 789 | + assert.equal(counts.relay, 1); | ||
| 790 | + const oversizedResponse = await rawPersistentRequest( | ||
| 791 | + address.port, | ||
| 792 | + `POST ${DEPARTURE_ROUTE} HTTP/1.1\r\n` | ||
| 793 | + + `Host: 127.0.0.1:${address.port}\r\n` | ||
| 794 | + + `Authorization: ${AUTHORIZATION_A}\r\n` | ||
| 795 | + + `Content-Length: ${DEPARTURE_ENDPOINT_LIMITS.requestBodyBytes + 1}\r\n` | ||
| 796 | + + "Expect: 100-continue\r\n" | ||
| 797 | + + "Connection: keep-alive\r\n\r\n", | ||
| 798 | + ); | ||
| 799 | + assert.match(oversizedResponse, /^HTTP\/1\.1 400 Bad Request\r\n/u); | ||
| 800 | + assert.equal(oversizedResponse.includes("100 Continue"), false); | ||
| 801 | + assert.match(oversizedResponse.toLowerCase(), /\r\nconnection: close\r\n/u); | ||
| 802 | + assert.match(oversizedResponse, /"code":"INVALID_SERVICE"/u); | ||
| 803 | + assert.equal(counts.catalog, 1); | ||
| 804 | + assert.equal(counts.relay, 1); | ||
| 805 | + } finally { | ||
| 806 | + const closed = once(server, "close"); | ||
| 807 | + server.close(); | ||
| 808 | + await closed; | ||
| 809 | + } | ||
| 810 | +}); | ||
added
packages/backend/test/departures.test.ts +1442 -0 | new file mode 100644 | ||
| @@ -0,0 +1,1442 @@ | ||
| 1 | +import assert from "node:assert/strict"; | |
| 2 | +import { readFile } from "node:fs/promises"; | |
| 3 | +import test from "node:test"; | |
| 4 | +import { performance } from "node:perf_hooks"; | |
| 5 | +import { | |
| 6 | + CACHE_FRESH_SECONDS, | |
| 7 | + LIMITS, | |
| 8 | + PRIM_ORIGIN, | |
| 9 | + SCHEMA_VERSION, | |
| 10 | + isDepartureResult, | |
| 11 | + isErrorResult, | |
| 12 | + utf8Bytes, | |
| 13 | + type Departure, | |
| 14 | + type DepartureResult, | |
| 15 | + type DepartureStatus, | |
| 16 | + type ErrorCode, | |
| 17 | + type ErrorResult, | |
| 18 | + type Freshness, | |
| 19 | + type TransportMode, | |
| 20 | +} from "../../contracts/src/index.ts"; | |
| 21 | +import { | |
| 22 | + DEPARTURE_METRIC_NAME, | |
| 23 | + DEPARTURE_ROUTE, | |
| 24 | + createDepartureRuntimeState, | |
| 25 | + departureHttpStatus, | |
| 26 | + handleDepartureHttpRequest, | |
| 27 | + handleDepartureRequest, | |
| 28 | + type DepartureHandlerResponse, | |
| 29 | + type DepartureMetrics, | |
| 30 | + type DepartureResolverRequest, | |
| 31 | + type DepartureResolverResult, | |
| 32 | + type DepartureRuntimeState, | |
| 33 | + type PublicDepartureData, | |
| 34 | + type PublicDepartureError, | |
| 35 | +} from "../src/departures.ts"; | |
| 36 | +import { | |
| 37 | + PrimNormalizationError, | |
| 38 | + buildPrimStopMonitoringUrl, | |
| 39 | + normalizePrimDepartureResponse, | |
| 40 | + parseRetryAfterSeconds, | |
| 41 | + type PrimNormalizedDepartureData, | |
| 42 | + type PrimServiceResolution, | |
| 43 | +} from "../src/prim-departures.ts"; | |
| 44 | +import { | |
| 45 | + createPrimDepartureResolver, | |
| 46 | + createUpstreamLimiter, | |
| 47 | + type PrimDepartureRelay, | |
| 48 | + type PrimDepartureRelayResponse, | |
| 49 | + type UpstreamLimiter, | |
| 50 | +} from "../src/departure-service.ts"; | |
| 51 | +import type { CatalogReader, CatalogServiceResolution } from "../src/catalog.ts"; | |
| 52 | +import { | |
| 53 | + relayPrimRequest, | |
| 54 | + type PrimFetch, | |
| 55 | + type PrimFetchInit, | |
| 56 | +} from "../src/index.ts"; | |
| 57 | + | |
| 58 | +const encoder = new TextEncoder(); | |
| 59 | +const KEY_A = "test-personal-key-a"; | |
| 60 | +const KEY_B = "test-personal-key-b"; | |
| 61 | +const KEY_C = "test-personal-key-c"; | |
| 62 | +const CLOCK_MS = Date.parse("2026-01-15T09:00:00Z"); | |
| 63 | +const CLOCK_SEC = CLOCK_MS / 1000; | |
| 64 | +const FETCHED_AT = Date.parse("2026-01-15T08:30:00Z") / 1000; | |
| 65 | + | |
| 66 | +const sec = (iso: string): number => Date.parse(iso) / 1000; | |
| 67 | + | |
| 68 | +const RESOLUTIONS = { | |
| 69 | + BUS: { | |
| 70 | + monitoringRef: "IDFM:SP:1001", | |
| 71 | + lineRef: "IDFM:C1001", | |
| 72 | + directionId: "IDFM:1001D", | |
| 73 | + destinationRef: "IDFM:1001DST", | |
| 74 | + }, | |
| 75 | + METRO: { | |
| 76 | + monitoringRef: "IDFM:SP:2001", | |
| 77 | + lineRef: "IDFM:C2001", | |
| 78 | + directionId: "IDFM:2001D", | |
| 79 | + destinationRef: "IDFM:2001DST", | |
| 80 | + }, | |
| 81 | + TRAM: { | |
| 82 | + monitoringRef: "IDFM:SP:3001", | |
| 83 | + lineRef: "IDFM:C3001", | |
| 84 | + directionId: "IDFM:3001D", | |
| 85 | + destinationRef: "IDFM:3001DST", | |
| 86 | + }, | |
| 87 | + RER: { | |
| 88 | + monitoringRef: "IDFM:SP:4001", | |
| 89 | + lineRef: "IDFM:C4001", | |
| 90 | + directionId: "IDFM:4001D", | |
| 91 | + destinationRef: "IDFM:4001DST", | |
| 92 | + }, | |
| 93 | + TRANSILIEN: { | |
| 94 | + monitoringRef: "IDFM:SP:5001", | |
| 95 | + lineRef: "IDFM:C5001", | |
| 96 | + directionId: "IDFM:5001D", | |
| 97 | + destinationRef: "IDFM:5001DST", | |
| 98 | + }, | |
| 99 | +} as const satisfies Record<TransportMode, PrimServiceResolution>; | |
| 100 | + | |
| 101 | +function expectedDeparture( | |
| 102 | + expectedIso: string, | |
| 103 | + minutes: number, | |
| 104 | + status: DepartureStatus, | |
| 105 | + options: { aimedIso?: string; interval?: number } = {}, | |
| 106 | +): Departure { | |
| 107 | + const departure: Departure = { expectedAt: sec(expectedIso), minutes, status }; | |
| 108 | + if (options.aimedIso !== undefined) departure.aimedAt = sec(options.aimedIso); | |
| 109 | + if (options.interval !== undefined) departure.nextIntervalMinutes = options.interval; | |
| 110 | + return departure; | |
| 111 | +} | |
| 112 | + | |
| 113 | +function expectedNormalized( | |
| 114 | + freshness: Freshness, | |
| 115 | + departures: Departure[], | |
| 116 | + sourceUpdatedAt: number = FETCHED_AT, | |
| 117 | +): PrimNormalizedDepartureData { | |
| 118 | + return { schemaVersion: SCHEMA_VERSION, fetchedAt: FETCHED_AT, sourceUpdatedAt, freshness, departures }; | |
| 119 | +} | |
| 120 | + | |
| 121 | +const FIXTURE_CACHE = new Map<string, unknown>(); | |
| 122 | + | |
| 123 | +async function loadFixture(name: string): Promise<unknown> { | |
| 124 | + const cached = FIXTURE_CACHE.get(name); | |
| 125 | + if (cached !== undefined) return cached; | |
| 126 | + const parsed: unknown = JSON.parse( | |
| 127 | + await readFile(new URL(`../../../fixtures/departures/prim/${name}.json`, import.meta.url), "utf8"), | |
| 128 | + ); | |
| 129 | + FIXTURE_CACHE.set(name, parsed); | |
| 130 | + return parsed; | |
| 131 | +} | |
| 132 | + | |
| 133 | +const MODE_FIXTURES: ReadonlyArray<{ | |
| 134 | + readonly fixture: string; | |
| 135 | + readonly mode: TransportMode; | |
| 136 | + readonly resolution: PrimServiceResolution; | |
| 137 | + readonly expected: PrimNormalizedDepartureData; | |
| 138 | +}> = [ | |
| 139 | + { | |
| 140 | + fixture: "bus", | |
| 141 | + mode: "BUS", | |
| 142 | + resolution: RESOLUTIONS.BUS, | |
| 143 | + expected: expectedNormalized("REALTIME", [ | |
| 144 | + expectedDeparture("2026-01-15T08:32:00Z", 2, "ON_TIME", { | |
| 145 | + aimedIso: "2026-01-15T08:31:00Z", | |
| 146 | + interval: 6, | |
| 147 | + }), | |
| 148 | + expectedDeparture("2026-01-15T08:38:00Z", 8, "ON_TIME", { | |
| 149 | + aimedIso: "2026-01-15T08:37:30Z", | |
| 150 | + }), | |
| 151 | + ]), | |
| 152 | + }, | |
| 153 | + { | |
| 154 | + fixture: "metro", | |
| 155 | + mode: "METRO", | |
| 156 | + resolution: RESOLUTIONS.METRO, | |
| 157 | + expected: expectedNormalized("REALTIME", [ | |
| 158 | + expectedDeparture("2026-01-15T08:34:00Z", 4, "ON_TIME", { | |
| 159 | + aimedIso: "2026-01-15T08:33:00Z", | |
| 160 | + }), | |
| 161 | + ]), | |
| 162 | + }, | |
| 163 | + { | |
| 164 | + fixture: "tram", | |
| 165 | + mode: "TRAM", | |
| 166 | + resolution: RESOLUTIONS.TRAM, | |
| 167 | + expected: expectedNormalized("REALTIME", [ | |
| 168 | + expectedDeparture("2026-01-15T08:36:00Z", 6, "ON_TIME", { | |
| 169 | + aimedIso: "2026-01-15T08:35:00Z", | |
| 170 | + }), | |
| 171 | + ]), | |
| 172 | + }, | |
| 173 | + { | |
| 174 | + fixture: "rer", | |
| 175 | + mode: "RER", | |
| 176 | + resolution: RESOLUTIONS.RER, | |
| 177 | + expected: expectedNormalized("REALTIME", [ | |
| 178 | + expectedDeparture("2026-01-15T08:33:00Z", 3, "ON_TIME", { | |
| 179 | + aimedIso: "2026-01-15T08:32:30Z", | |
| 180 | + interval: 14, | |
| 181 | + }), | |
| 182 | + expectedDeparture("2026-01-15T08:47:00Z", 17, "ON_TIME", { | |
| 183 | + aimedIso: "2026-01-15T08:45:00Z", | |
| 184 | + }), | |
| 185 | + ]), | |
| 186 | + }, | |
| 187 | + { | |
| 188 | + fixture: "transilien", | |
| 189 | + mode: "TRANSILIEN", | |
| 190 | + resolution: RESOLUTIONS.TRANSILIEN, | |
| 191 | + expected: expectedNormalized("REALTIME", [ | |
| 192 | + expectedDeparture("2026-01-15T08:41:00Z", 11, "ON_TIME", { | |
| 193 | + aimedIso: "2026-01-15T08:40:00Z", | |
| 194 | + interval: 14, | |
| 195 | + }), | |
| 196 | + expectedDeparture("2026-01-15T08:55:00Z", 25, "ON_TIME", { | |
| 197 | + aimedIso: "2026-01-15T08:54:00Z", | |
| 198 | + }), | |
| 199 | + ]), | |
| 200 | + }, | |
| 201 | +]; | |
| 202 | + | |
| 203 | +interface RecordedMetric { | |
| 204 | + readonly name: keyof typeof DEPARTURE_METRIC_NAME; | |
| 205 | + readonly value: number; | |
| 206 | +} | |
| 207 | + | |
| 208 | +function metricsRecorder(): DepartureMetrics & { | |
| 209 | + readonly entries: RecordedMetric[]; | |
| 210 | + count(name: keyof typeof DEPARTURE_METRIC_NAME): number; | |
| 211 | +} { | |
| 212 | + const entries: RecordedMetric[] = []; | |
| 213 | + return { | |
| 214 | + entries, | |
| 215 | + record(name, value) { | |
| 216 | + entries.push({ name, value }); | |
| 217 | + }, | |
| 218 | + count(name) { | |
| 219 | + return entries.filter((entry) => entry.name === name).length; | |
| 220 | + }, | |
| 221 | + }; | |
| 222 | +} | |
| 223 | + | |
| 224 | +function flush(): Promise<void> { | |
| 225 | + const { promise, resolve } = Promise.withResolvers<void>(); | |
| 226 | + setImmediate(resolve); | |
| 227 | + return promise; | |
| 228 | +} | |
| 229 | + | |
| 230 | +function snapshot(fetchedAt: number, sourceUpdatedAt = fetchedAt - 10): PublicDepartureData { | |
| 231 | + return { | |
| 232 | + schemaVersion: SCHEMA_VERSION, | |
| 233 | + fetchedAt, | |
| 234 | + sourceUpdatedAt, | |
| 235 | + freshness: "REALTIME", | |
| 236 | + departures: [ | |
| 237 | + { expectedAt: fetchedAt + 120, aimedAt: fetchedAt + 60, minutes: 2, status: "ON_TIME" }, | |
| 238 | + { expectedAt: fetchedAt + 360, minutes: 6, status: "DELAYED" }, | |
| 239 | + ], | |
| 240 | + }; | |
| 241 | +} | |
| 242 | + | |
| 243 | +function errorSnapshot( | |
| 244 | + code: ErrorCode, | |
| 245 | + occurredAt: number, | |
| 246 | + retryAfterSeconds?: number, | |
| 247 | +): PublicDepartureError { | |
| 248 | + return { | |
| 249 | + schemaVersion: SCHEMA_VERSION, | |
| 250 | + code, | |
| 251 | + occurredAt, | |
| 252 | + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), | |
| 253 | + }; | |
| 254 | +} | |
| 255 | + | |
| 256 | +interface HandlerCallOptions { | |
| 257 | + readonly serviceId: string; | |
| 258 | + readonly requestId?: string; | |
| 259 | + readonly favoriteId?: string; | |
| 260 | + readonly apiKey?: string; | |
| 261 | + readonly state: DepartureRuntimeState; | |
| 262 | + readonly resolve: (request: DepartureResolverRequest) => Promise<DepartureResolverResult>; | |
| 263 | + readonly nowMilliseconds?: () => number; | |
| 264 | + readonly metrics?: DepartureMetrics; | |
| 265 | +} | |
| 266 | + | |
| 267 | +function requestDepartures(options: HandlerCallOptions): Promise<DepartureHandlerResponse> { | |
| 268 | + return handleDepartureRequest( | |
| 269 | + { | |
| 270 | + authorization: `Bearer ${options.apiKey ?? KEY_A}`, | |
| 271 | + body: { | |
| 272 | + schemaVersion: SCHEMA_VERSION, | |
| 273 | + requestId: options.requestId ?? "dep-request", | |
| 274 | + favoriteId: options.favoriteId ?? "dep-favorite", | |
| 275 | + serviceId: options.serviceId, | |
| 276 | + }, | |
| 277 | + }, | |
| 278 | + { | |
| 279 | + resolve: options.resolve, | |
| 280 | + state: options.state, | |
| 281 | + ...(options.nowMilliseconds === undefined ? {} : { nowMilliseconds: options.nowMilliseconds }), | |
| 282 | + ...(options.metrics === undefined ? {} : { metrics: options.metrics }), | |
| 283 | + }, | |
| 284 | + ); | |
| 285 | +} | |
| 286 | + | |
| 287 | +function expectDeparture(result: DepartureHandlerResponse): DepartureResult { | |
| 288 | + assert.equal(isDepartureResult(result), true); | |
| 289 | + if (!isDepartureResult(result)) assert.fail("expected a departure result"); | |
| 290 | + return result; | |
| 291 | +} | |
| 292 | + | |
| 293 | +function expectError(result: DepartureHandlerResponse): ErrorResult { | |
| 294 | + assert.equal(isErrorResult(result), true); | |
| 295 | + if (!isErrorResult(result)) assert.fail("expected an error result"); | |
| 296 | + return result; | |
| 297 | +} | |
| 298 | + | |
| 299 | +function expectResolverError(result: DepartureResolverResult): PublicDepartureError { | |
| 300 | + if ("code" in result) return result; | |
| 301 | + assert.fail("expected a public departure error"); | |
| 302 | +} | |
| 303 | + | |
| 304 | +function recordingResolver( | |
| 305 | + produce: (request: DepartureResolverRequest, call: number) => Promise<DepartureResolverResult>, | |
| 306 | +): { readonly seen: DepartureResolverRequest[]; readonly resolve: HandlerCallOptions["resolve"] } { | |
| 307 | + const seen: DepartureResolverRequest[] = []; | |
| 308 | + return { | |
| 309 | + seen, | |
| 310 | + resolve: async (request) => { | |
| 311 | + seen.push({ ...request }); | |
| 312 | + return produce(request, seen.length); | |
| 313 | + }, | |
| 314 | + }; | |
| 315 | +} | |
| 316 | + | |
| 317 | +test("normalize maps the five deterministic mode fixtures to frozen REALTIME results", async () => { | |
| 318 | + for (const entry of MODE_FIXTURES) { | |
| 319 | + const result = normalizePrimDepartureResponse( | |
| 320 | + await loadFixture(entry.fixture), | |
| 321 | + entry.resolution, | |
| 322 | + FETCHED_AT, | |
| 323 | + ); | |
| 324 | + assert.deepEqual(result, entry.expected, `fixture ${entry.fixture} (${entry.mode})`); | |
| 325 | + assert.equal(Object.isFrozen(result), true); | |
| 326 | + assert.equal(Object.isFrozen(result.departures), true); | |
| 327 | + for (const departure of result.departures) assert.equal(Object.isFrozen(departure), true); | |
| 328 | + } | |
| 329 | +}); | |
| 330 | + | |
| 331 | +test("delayed fixture keeps expected and aimed distinct and computes minutes from expected", async () => { | |
| 332 | + const result = normalizePrimDepartureResponse( | |
| 333 | + await loadFixture("delayed"), | |
| 334 | + RESOLUTIONS.METRO, | |
| 335 | + FETCHED_AT, | |
| 336 | + ); | |
| 337 | + assert.deepEqual(result, expectedNormalized("REALTIME", [ | |
| 338 | + expectedDeparture("2026-01-15T08:37:00Z", 7, "DELAYED", { | |
| 339 | + aimedIso: "2026-01-15T08:33:00Z", | |
| 340 | + }), | |
| 341 | + ])); | |
| 342 | +}); | |
| 343 | + | |
| 344 | +test("cancelled fixture emits CANCELLED without intervals and skips it as an interval anchor", async () => { | |
| 345 | + const result = normalizePrimDepartureResponse( | |
| 346 | + await loadFixture("cancelled"), | |
| 347 | + RESOLUTIONS.TRAM, | |
| 348 | + FETCHED_AT, | |
| 349 | + ); | |
| 350 | + assert.deepEqual(result, expectedNormalized("REALTIME", [ | |
| 351 | + expectedDeparture("2026-01-15T08:32:00Z", 2, "CANCELLED", { | |
| 352 | + aimedIso: "2026-01-15T08:31:30Z", | |
| 353 | + }), | |
| 354 | + expectedDeparture("2026-01-15T08:35:00Z", 5, "ON_TIME", { | |
| 355 | + aimedIso: "2026-01-15T08:34:30Z", | |
| 356 | + interval: 5, | |
| 357 | + }), | |
| 358 | + expectedDeparture("2026-01-15T08:40:00Z", 10, "ON_TIME", { | |
| 359 | + aimedIso: "2026-01-15T08:39:30Z", | |
| 360 | + }), | |
| 361 | + ])); | |
| 362 | + assert.equal("nextIntervalMinutes" in result.departures[0]!, false); | |
| 363 | +}); | |
| 364 | + | |
| 365 | +test("scheduled-only fixture falls back to aimed times with SCHEDULED freshness and UNKNOWN status", async () => { | |
| 366 | + const result = normalizePrimDepartureResponse( | |
| 367 | + await loadFixture("scheduled-only"), | |
| 368 | + RESOLUTIONS.TRANSILIEN, | |
| 369 | + FETCHED_AT, | |
| 370 | + ); | |
| 371 | + assert.deepEqual(result, expectedNormalized("SCHEDULED", [ | |
| 372 | + expectedDeparture("2026-01-15T08:44:00Z", 14, "UNKNOWN", { | |
| 373 | + aimedIso: "2026-01-15T08:44:00Z", | |
| 374 | + interval: 14, | |
| 375 | + }), | |
| 376 | + expectedDeparture("2026-01-15T08:58:00Z", 28, "UNKNOWN", { | |
| 377 | + aimedIso: "2026-01-15T08:58:00Z", | |
| 378 | + }), | |
| 379 | + ])); | |
| 380 | +}); | |
| 381 | + | |
| 382 | +test("mixed fixture labels the result MIXED exactly when expected and aimed-only visits coexist", async () => { | |
| 383 | + const result = normalizePrimDepartureResponse( | |
| 384 | + await loadFixture("mixed"), | |
| 385 | + RESOLUTIONS.RER, | |
| 386 | + FETCHED_AT, | |
| 387 | + ); | |
| 388 | + assert.deepEqual(result, expectedNormalized("MIXED", [ | |
| 389 | + expectedDeparture("2026-01-15T08:33:00Z", 3, "ON_TIME", { | |
| 390 | + aimedIso: "2026-01-15T08:32:30Z", | |
| 391 | + interval: 8, | |
| 392 | + }), | |
| 393 | + expectedDeparture("2026-01-15T08:41:00Z", 11, "UNKNOWN", { | |
| 394 | + aimedIso: "2026-01-15T08:41:00Z", | |
| 395 | + interval: 8, | |
| 396 | + }), | |
| 397 | + expectedDeparture("2026-01-15T08:49:00Z", 19, "ON_TIME", { | |
| 398 | + aimedIso: "2026-01-15T08:48:00Z", | |
| 399 | + }), | |
| 400 | + ])); | |
| 401 | +}); | |
| 402 | + | |
| 403 | +test("partial fixture filters foreign stops and lines and derives sourceUpdatedAt from matched visits only", async () => { | |
| 404 | + const result = normalizePrimDepartureResponse( | |
| 405 | + await loadFixture("partial"), | |
| 406 | + RESOLUTIONS.BUS, | |
| 407 | + FETCHED_AT, | |
| 408 | + ); | |
| 409 | + assert.deepEqual(result, expectedNormalized( | |
| 410 | + "REALTIME", | |
| 411 | + [ | |
| 412 | + expectedDeparture("2026-01-15T08:34:00Z", 4, "ON_TIME", { | |
| 413 | + aimedIso: "2026-01-15T08:33:00Z", | |
| 414 | + interval: 8, | |
| 415 | + }), | |
| 416 | + expectedDeparture("2026-01-15T08:42:00Z", 12, "ON_TIME"), | |
| 417 | + ], | |
| 418 | + sec("2026-01-15T08:29:35Z"), | |
| 419 | + )); | |
| 420 | + assert.equal("aimedAt" in result.departures[1]!, false); | |
| 421 | +}); | |
| 422 | + | |
| 423 | +test("malformed fixture without call times is rejected with a stable normalization error", async () => { | |
| 424 | + const payload = await loadFixture("malformed"); | |
| 425 | + assert.throws( | |
| 426 | + () => normalizePrimDepartureResponse(payload, RESOLUTIONS.METRO, FETCHED_AT), | |
| 427 | + (error: unknown) => | |
| 428 | + error instanceof PrimNormalizationError | |
| 429 | + && /ExpectedDepartureTime or AimedDepartureTime/u.test(error.message), | |
| 430 | + ); | |
| 431 | +}); | |
| 432 | + | |
| 433 | +test("structurally malformed responses reject with PrimNormalizationError instead of guessing", () => { | |
| 434 | + const validVisit = { | |
| 435 | + RecordedAtTime: "2026-01-15T08:29:45Z", | |
| 436 | + MonitoringRef: { value: RESOLUTIONS.BUS.monitoringRef }, | |
| 437 | + MonitoredVehicleJourney: { | |
| 438 | + LineRef: { value: RESOLUTIONS.BUS.lineRef }, | |
| 439 | + MonitoredCall: { ExpectedDepartureTime: "2026-01-15T08:32:00Z" }, | |
| 440 | + }, | |
| 441 | + }; | |
| 442 | + const envelope = (delivery: unknown) => ({ | |
| 443 | + Siri: { ServiceDelivery: { StopMonitoringDelivery: [delivery] } }, | |
| 444 | + }); | |
| 445 | + const cases: ReadonlyArray<[unknown, RegExp]> = [ | |
| 446 | + [null, /response must be a JSON object/u], | |
| 447 | + [{}, /Siri must be an object/u], | |
| 448 | + [{ Siri: {} }, /Siri\.ServiceDelivery must be an object/u], | |
| 449 | + [envelope({ MonitoredStopVisit: {} }), /MonitoredStopVisit must be an array/u], | |
| 450 | + [envelope({ MonitoredStopVisit: [{ ...validVisit, RecordedAtTime: 17 }] }), /RecordedAtTime/u], | |
| 451 | + [envelope({ MonitoredStopVisit: [{ ...validVisit, MonitoringRef: { value: "" } }] }), /MonitoringRef/u], | |
| 452 | + [ | |
| 453 | + envelope({ MonitoredStopVisit: [{ ...validVisit, MonitoringRef: { value: "IDFM:SP:9999" } }] }), | |
| 454 | + /no MonitoredStopVisit matches/u, | |
| 455 | + ], | |
| 456 | + ]; | |
| 457 | + for (const [payload, pattern] of cases) { | |
| 458 | + assert.throws( | |
| 459 | + () => normalizePrimDepartureResponse(payload, RESOLUTIONS.BUS, FETCHED_AT), | |
| 460 | + (error: unknown) => error instanceof PrimNormalizationError && pattern.test(error.message), | |
| 461 | + ); | |
| 462 | + } | |
| 463 | +}); | |
| 464 | + | |
| 465 | +test("normalization accepts RFC3339 timestamps with an explicit numeric offset", () => { | |
| 466 | + const result = normalizePrimDepartureResponse( | |
| 467 | + { | |
| 468 | + Siri: { | |
| 469 | + ServiceDelivery: { | |
| 470 | + ResponseTimestamp: "2026-01-15T09:30:00.250+01:00", | |
| 471 | + StopMonitoringDelivery: [{ | |
| 472 | + ResponseTimestamp: "2026-01-15T09:29:58+01:00", | |
| 473 | + MonitoredStopVisit: [{ | |
| 474 | + RecordedAtTime: "2026-01-15T09:29:45+01:00", | |
| 475 | + MonitoringRef: { value: RESOLUTIONS.BUS.monitoringRef }, | |
| 476 | + MonitoredVehicleJourney: { | |
| 477 | + LineRef: { value: RESOLUTIONS.BUS.lineRef }, | |
| 478 | + MonitoredCall: { | |
| 479 | + ExpectedDepartureTime: "2026-01-15T09:32:00.999+01:00", | |
| 480 | + AimedDepartureTime: "2026-01-15T09:31:00+01:00", | |
| 481 | + DepartureStatus: "ontime", | |
| 482 | + }, | |
| 483 | + }, | |
| 484 | + }], | |
| 485 | + }], | |
| 486 | + }, | |
| 487 | + }, | |
| 488 | + }, | |
| 489 | + RESOLUTIONS.BUS, | |
| 490 | + FETCHED_AT, | |
| 491 | + ); | |
| 492 | + | |
| 493 | + assert.deepEqual(result, expectedNormalized("REALTIME", [ | |
| 494 | + expectedDeparture("2026-01-15T08:32:00Z", 2, "ON_TIME", { | |
| 495 | + aimedIso: "2026-01-15T08:31:00Z", | |
| 496 | + }), | |
| 497 | + ])); | |
| 498 | +}); | |
| 499 | + | |
| 500 | +test("normalization rejects timestamps without an explicit offset or with invalid components", () => { | |
| 501 | + const payload = (timestamp: string) => ({ | |
| 502 | + Siri: { | |
| 503 | + ServiceDelivery: { | |
| 504 | + StopMonitoringDelivery: [{ | |
| 505 | + MonitoredStopVisit: [{ | |
| 506 | + RecordedAtTime: "2026-01-15T08:29:45Z", | |
| 507 | + MonitoringRef: { value: RESOLUTIONS.BUS.monitoringRef }, | |
| 508 | + MonitoredVehicleJourney: { | |
| 509 | + LineRef: { value: RESOLUTIONS.BUS.lineRef }, | |
| 510 | + MonitoredCall: { ExpectedDepartureTime: timestamp }, | |
| 511 | + }, | |
| 512 | + }], | |
| 513 | + }], | |
| 514 | + }, | |
| 515 | + }, | |
| 516 | + }); | |
| 517 | + const invalid = [ | |
| 518 | + "2026-01-15T08:32:00", | |
| 519 | + "2026-02-29T08:32:00Z", | |
| 520 | + "2026-01-15T24:00:00Z", | |
| 521 | + "2026-01-15T08:60:00Z", | |
| 522 | + "2026-01-15T08:32:60Z", | |
| 523 | + "2026-01-15T08:32:00+24:00", | |
| 524 | + "2026-01-15T08:32:00+01:60", | |
| 525 | + "2026-01-15T08:32:00z", | |
| 526 | + "2026-01-15T08:32:00Z\n", | |
| 527 | + "1969-12-31T23:59:59Z", | |
| 528 | + "2106-02-07T06:28:16Z", | |
| 529 | + ] as const; | |
| 530 | + | |
| 531 | + for (const timestamp of invalid) { | |
| 532 | + assert.throws( | |
| 533 | + () => normalizePrimDepartureResponse(payload(timestamp), RESOLUTIONS.BUS, FETCHED_AT), | |
| 534 | + (error: unknown) => | |
| 535 | + error instanceof PrimNormalizationError | |
| 536 | + && /ExpectedDepartureTime is not a valid contract timestamp/u.test(error.message), | |
| 537 | + timestamp, | |
| 538 | + ); | |
| 539 | + } | |
| 540 | +}); | |
| 541 | + | |
| 542 | +test("normalization sorts by expected time and bounds results to the shared departure limit", () => { | |
| 543 | + const visit = (expectedIso: string) => ({ | |
| 544 | + RecordedAtTime: "2026-01-15T08:29:50Z", | |
| 545 | + MonitoringRef: { value: RESOLUTIONS.BUS.monitoringRef }, | |
| 546 | + MonitoredVehicleJourney: { | |
| 547 | + LineRef: { value: RESOLUTIONS.BUS.lineRef }, | |
| 548 | + MonitoredCall: { ExpectedDepartureTime: expectedIso, DepartureStatus: "ontime" }, | |
| 549 | + }, | |
| 550 | + }); | |
| 551 | + const result = normalizePrimDepartureResponse( | |
| 552 | + { | |
| 553 | + Siri: { | |
| 554 | + ServiceDelivery: { | |
| 555 | + StopMonitoringDelivery: [{ | |
| 556 | + MonitoredStopVisit: ["08:40", "08:35", "08:38", "08:37", "08:36", "08:39"] | |
| 557 | + .map((time) => visit(`2026-01-15T${time}:00Z`)), | |
| 558 | + }], | |
| 559 | + }, | |
| 560 | + }, | |
| 561 | + }, | |
| 562 | + RESOLUTIONS.BUS, | |
| 563 | + FETCHED_AT, | |
| 564 | + ); | |
| 565 | + assert.equal(result.departures.length, LIMITS.departures); | |
| 566 | + assert.deepEqual(result.departures.map((departure) => departure.minutes), [5, 6, 7, 8]); | |
| 567 | + assert.deepEqual( | |
| 568 | + result.departures.slice(0, -1).map((departure) => departure.nextIntervalMinutes), | |
| 569 | + [1, 1, 1], | |
| 570 | + ); | |
| 571 | +}); | |
| 572 | + | |
| 573 | +test("buildPrimStopMonitoringUrl targets the exact PRIM path with ordered MonitoringRef and LineRef", () => { | |
| 574 | + const url = buildPrimStopMonitoringUrl(RESOLUTIONS.BUS); | |
| 575 | + assert.equal(url.origin, PRIM_ORIGIN); | |
| 576 | + assert.equal(url.pathname, "/marketplace/stop-monitoring"); | |
| 577 | + assert.deepEqual([...url.searchParams.keys()], ["MonitoringRef", "LineRef"]); | |
| 578 | + assert.equal(url.searchParams.get("MonitoringRef"), RESOLUTIONS.BUS.monitoringRef); | |
| 579 | + assert.equal(url.searchParams.get("LineRef"), RESOLUTIONS.BUS.lineRef); | |
| 580 | + const encoded = (value: string) => encodeURIComponent(value); | |
| 581 | + assert.equal( | |
| 582 | + url.href, | |
| 583 | + `${PRIM_ORIGIN}/marketplace/stop-monitoring` | |
| 584 | + + `?MonitoringRef=${encoded(RESOLUTIONS.BUS.monitoringRef)}` | |
| 585 | + + `&LineRef=${encoded(RESOLUTIONS.BUS.lineRef)}`, | |
| 586 | + ); | |
| 587 | +}); | |
| 588 | + | |
| 589 | +test("parseRetryAfterSeconds accepts only bounded non-negative integer delta-seconds", () => { | |
| 590 | + const cases: ReadonlyArray<[unknown, number | undefined]> = [ | |
| 591 | + ["30", 30], | |
| 592 | + ["0", 0], | |
| 593 | + ["86400", 86_400], | |
| 594 | + ["007", 7], | |
| 595 | + ["86401", undefined], | |
| 596 | + ["-5", undefined], | |
| 597 | + ["1.5", undefined], | |
| 598 | + ["soon", undefined], | |
| 599 | + ["", undefined], | |
| 600 | + [90, 90], | |
| 601 | + [0, 0], | |
| 602 | + [86_400, 86_400], | |
| 603 | + [86_401, undefined], | |
| 604 | + [-1, undefined], | |
| 605 | + [1.5, undefined], | |
| 606 | + [Number.NaN, undefined], | |
| 607 | + [null, undefined], | |
| 608 | + [undefined, undefined], | |
| 609 | + ]; | |
| 610 | + for (const [value, expected] of cases) { | |
| 611 | + assert.equal(parseRetryAfterSeconds(value), expected, `value ${String(value)}`); | |
| 612 | + } | |
| 613 | +}); | |
| 614 | + | |
| 615 | +function catalogReader(table: Record<string, CatalogServiceResolution>): CatalogReader { | |
| 616 | + return { | |
| 617 | + searchPlaces: () => [], | |
| 618 | + listServices: () => undefined, | |
| 619 | + resolveService: (serviceId) => | |
| 620 | + table[serviceId] ?? { status: "UNRESOLVED", code: "INVALID_SERVICE" }, | |
| 621 | + }; | |
| 622 | +} | |
| 623 | + | |
| 624 | +function relayJson(payload: unknown, status = 200, retryAfter?: string): PrimDepartureRelayResponse { | |
| 625 | + return { | |
| 626 | + status, | |
| 627 | + bytes: encoder.encode(JSON.stringify(payload)), | |
| 628 | + ...(retryAfter === undefined ? {} : { retryAfter }), | |
| 629 | + }; | |
| 630 | +} | |
| 631 | + | |
| 632 | +const BUS_SERVICE = "svc-bus"; | |
| 633 | + | |
| 634 | +function busResolver( | |
| 635 | + relay: PrimDepartureRelay, | |
| 636 | + options: { | |
| 637 | + readonly limiter?: UpstreamLimiter; | |
| 638 | + readonly metrics?: DepartureMetrics; | |
| 639 | + readonly clock?: () => number; | |
| 640 | + } = {}, | |
| 641 | +) { | |
| 642 | + return createPrimDepartureResolver({ | |
| 643 | + catalog: catalogReader({ | |
| 644 | + [BUS_SERVICE]: { status: "RESOLVED", ...RESOLUTIONS.BUS }, | |
| 645 | + }), | |
| 646 | + relay, | |
| 647 | + clock: options.clock ?? (() => CLOCK_MS), | |
| 648 | + ...(options.metrics === undefined ? {} : { metrics: options.metrics }), | |
| 649 | + ...(options.limiter === undefined ? {} : { limiter: options.limiter }), | |
| 650 | + }); | |
| 651 | +} | |
| 652 | + | |
| 653 | +test("resolver relays the exact PRIM URL and headers and normalizes the bus fixture", async () => { | |
| 654 | + const busFixture = await loadFixture("bus"); | |
| 655 | + const captures: Array<{ target: URL; init: PrimFetchInit }> = []; | |
| 656 | + const fetchFake: PrimFetch = async (target, init) => { | |
| 657 | + captures.push({ target, init }); | |
| 658 | + return { | |
| 659 | + status: 200, | |
| 660 | + headers: { get: () => null }, | |
| 661 | + body: new ReadableStream<Uint8Array>({ | |
| 662 | + start(controller) { | |
| 663 | + controller.enqueue(encoder.encode(JSON.stringify(busFixture))); | |
| 664 | + controller.close(); | |
| 665 | + }, | |
| 666 | + }), | |
| 667 | + }; | |
| 668 | + }; | |
| 669 | + const resolver = busResolver(({ target, apiKey }) => | |
| 670 | + relayPrimRequest({ target, authorization: `Bearer ${apiKey}` }, { fetch: fetchFake })); | |
| 671 | + const result = await resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }); | |
| 672 | + | |
| 673 | + assert.deepEqual(result, normalizePrimDepartureResponse(busFixture, RESOLUTIONS.BUS, CLOCK_SEC)); | |
| 674 | + assert.equal(captures.length, 1); | |
| 675 | + const { target, init } = captures[0]!; | |
| 676 | + assert.equal(target.href, buildPrimStopMonitoringUrl(RESOLUTIONS.BUS).href); | |
| 677 | + assert.equal(init.method, "GET"); | |
| 678 | + assert.deepEqual(init.headers, { | |
| 679 | + apikey: KEY_A, | |
| 680 | + accept: "application/json", | |
| 681 | + "accept-encoding": "gzip", | |
| 682 | + }); | |
| 683 | + assert.equal(init.redirect, "manual"); | |
| 684 | + assert.equal(init.credentials, "omit"); | |
| 685 | + assert.equal(init.cache, "no-store"); | |
| 686 | +}); | |
| 687 | + | |
| 688 | +test("resolver rejects unknown catalog services without relay or upstream metrics", async () => { | |
| 689 | + const metrics = metricsRecorder(); | |
| 690 | + let relayCalls = 0; | |
| 691 | + const resolver = busResolver(async () => { | |
| 692 | + relayCalls += 1; | |
| 693 | + return relayJson({}); | |
| 694 | + }, { metrics }); | |
| 695 | + const result = await resolver({ serviceId: "unknown-service", apiKey: KEY_A }); | |
| 696 | + assert.deepEqual(result, errorSnapshot("INVALID_SERVICE", CLOCK_SEC)); | |
| 697 | + assert.equal(relayCalls, 0); | |
| 698 | + assert.deepEqual(metrics.entries, []); | |
| 699 | +}); | |
| 700 | + | |
| 701 | +test("resolver records actual upstream 401 and 403 statuses before mapping them", async () => { | |
| 702 | + for (const status of [401, 403]) { | |
| 703 | + const metrics = metricsRecorder(); | |
| 704 | + const resolver = busResolver( | |
| 705 | + async () => relayJson({ message: "nope" }, status), | |
| 706 | + { metrics }, | |
| 707 | + ); | |
| 708 | + const result = expectResolverError( | |
| 709 | + await resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | |
| 710 | + ); | |
| 711 | + assert.deepEqual(result, errorSnapshot("API_KEY_INVALID", CLOCK_SEC)); | |
| 712 | + assert.deepEqual(metrics.entries, [ | |
| 713 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, value: 1 }, | |
| 714 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, value: status }, | |
| 715 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, value: 0 }, | |
| 716 | + ]); | |
| 717 | + } | |
| 718 | +}); | |
| 719 | + | |
| 720 | +test("resolver ignores metric recorder failures", async () => { | |
| 721 | + const busFixture = await loadFixture("bus"); | |
| 722 | + let metricCalls = 0; | |
| 723 | + const metrics: DepartureMetrics = { | |
| 724 | + record() { | |
| 725 | + metricCalls += 1; | |
| 726 | + throw new Error("metric recorder unavailable"); | |
| 727 | + }, | |
| 728 | + }; | |
| 729 | + const resolver = busResolver( | |
| 730 | + async () => relayJson(busFixture), | |
| 731 | + { metrics }, | |
| 732 | + ); | |
| 733 | + | |
| 734 | + const result = await resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }); | |
| 735 | + assert.deepEqual(result, normalizePrimDepartureResponse(busFixture, RESOLUTIONS.BUS, CLOCK_SEC)); | |
| 736 | + assert.equal(metricCalls, 3); | |
| 737 | +}); | |
| 738 | + | |
| 739 | +test("resolver maps 429 to RATE_LIMITED carrying only validated Retry-After values", async () => { | |
| 740 | + const limited = expectResolverError( | |
| 741 | + await busResolver(async () => relayJson({}, 429, "30"))({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | |
| 742 | + ); | |
| 743 | + assert.deepEqual(limited, errorSnapshot("RATE_LIMITED", CLOCK_SEC, 30)); | |
| 744 | + | |
| 745 | + const unparsable = expectResolverError( | |
| 746 | + await busResolver(async () => relayJson({}, 429, "later"))({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | |
| 747 | + ); | |
| 748 | + assert.deepEqual(Object.keys(unparsable).sort(), ["code", "occurredAt", "schemaVersion"]); | |
| 749 | + | |
| 750 | + const serverError = expectResolverError( | |
| 751 | + await busResolver(async () => relayJson({}, 503))({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | |
| 752 | + ); | |
| 753 | + assert.deepEqual(serverError, errorSnapshot("SOURCE_UNAVAILABLE", CLOCK_SEC)); | |
| 754 | +}); | |
| 755 | + | |
| 756 | +test("resolver maps malformed, non-UTF-8, thrown, and invalid relay results to stable errors", async () => { | |
| 757 | + const malformed = expectResolverError( | |
| 758 | + await busResolver(async () => relayJson(await loadFixture("malformed")))({ | |
| 759 | + serviceId: BUS_SERVICE, | |
| 760 | + apiKey: KEY_A, | |
| 761 | + }), | |
| 762 | + ); | |
| 763 | + assert.deepEqual(malformed, errorSnapshot("INVALID_RESPONSE", CLOCK_SEC)); | |
| 764 | + | |
| 765 | + const invalidUtf8 = expectResolverError( | |
| 766 | + await busResolver(async () => ({ status: 200, bytes: new Uint8Array([0xff, 0xfe]) }))({ | |
| 767 | + serviceId: BUS_SERVICE, | |
| 768 | + apiKey: KEY_A, | |
| 769 | + }), | |
| 770 | + ); | |
| 771 | + assert.deepEqual(invalidUtf8, errorSnapshot("INVALID_RESPONSE", CLOCK_SEC)); | |
| 772 | + | |
| 773 | + const thrownMetrics = metricsRecorder(); | |
| 774 | + const thrown = expectResolverError( | |
| 775 | + await busResolver(async () => { | |
| 776 | + throw new Error(`relay exploded for ${KEY_A}`); | |
| 777 | + }, { metrics: thrownMetrics })({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | |
| 778 | + ); | |
| 779 | + assert.deepEqual(thrown, errorSnapshot("SOURCE_UNAVAILABLE", CLOCK_SEC)); | |
| 780 | + assert.deepEqual(thrownMetrics.entries, [ | |
| 781 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, value: 1 }, | |
| 782 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, value: 0 }, | |
| 783 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, value: 0 }, | |
| 784 | + ]); | |
| 785 | + | |
| 786 | + const invalidEnvelope = expectResolverError( | |
| 787 | + await busResolver( | |
| 788 | + async () => ({ status: "200" }) as unknown as PrimDepartureRelayResponse, | |
| 789 | + )({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | |
| 790 | + ); | |
| 791 | + assert.deepEqual(invalidEnvelope, errorSnapshot("INVALID_RESPONSE", CLOCK_SEC)); | |
| 792 | +}); | |
| 793 | + | |
| 794 | +test("upstream limiter enforces its configured global maximum and releases queued work", async () => { | |
| 795 | + assert.throws(() => createUpstreamLimiter(0), RangeError); | |
| 796 | + assert.throws(() => createUpstreamLimiter(1.5), RangeError); | |
| 797 | + | |
| 798 | + const limiter = createUpstreamLimiter(3); | |
| 799 | + let started = 0; | |
| 800 | + let concurrent = 0; | |
| 801 | + let peak = 0; | |
| 802 | + const gates = Array.from({ length: 5 }, () => Promise.withResolvers<void>()); | |
| 803 | + const runs = gates.map((gate) => | |
| 804 | + limiter.run(async () => { | |
| 805 | + started += 1; | |
| 806 | + concurrent += 1; | |
| 807 | + peak = Math.max(peak, concurrent); | |
| 808 | + await gate.promise; | |
| 809 | + concurrent -= 1; | |
| 810 | + return started; | |
| 811 | + })); | |
| 812 | + | |
| 813 | + await flush(); | |
| 814 | + assert.equal(started, 3); | |
| 815 | + assert.equal(peak, 3); | |
| 816 | + | |
| 817 | + gates[0]!.resolve(); | |
| 818 | + await runs[0]!; | |
| 819 | + await flush(); | |
| 820 | + assert.equal(started, 4); | |
| 821 | + assert.equal(peak, 3); | |
| 822 | + | |
| 823 | + gates[1]!.resolve(); | |
| 824 | + gates[2]!.resolve(); | |
| 825 | + await Promise.all([runs[1]!, runs[2]!]); | |
| 826 | + await flush(); | |
| 827 | + assert.equal(started, 5); | |
| 828 | + assert.equal(peak, 3); | |
| 829 | + | |
| 830 | + gates[3]!.resolve(); | |
| 831 | + gates[4]!.resolve(); | |
| 832 | + assert.equal((await Promise.all(runs)).length, 5); | |
| 833 | + assert.equal(peak, 3); | |
| 834 | +}); | |
| 835 | + | |
| 836 | +test("resolver metrics exclude limiter queue time and cover only active relay calls", async () => { | |
| 837 | + const limiter = createUpstreamLimiter(1); | |
| 838 | + const metrics = metricsRecorder(); | |
| 839 | + const response = relayJson(await loadFixture("bus")); | |
| 840 | + let now = CLOCK_MS; | |
| 841 | + let relayCalls = 0; | |
| 842 | + let relayPeak = 0; | |
| 843 | + let relayActive = 0; | |
| 844 | + const gates = [ | |
| 845 | + Promise.withResolvers<PrimDepartureRelayResponse>(), | |
| 846 | + Promise.withResolvers<PrimDepartureRelayResponse>(), | |
| 847 | + ]; | |
| 848 | + const resolver = busResolver(async () => { | |
| 849 | + relayCalls += 1; | |
| 850 | + relayActive += 1; | |
| 851 | + relayPeak = Math.max(relayPeak, relayActive); | |
| 852 | + try { | |
| 853 | + return await gates[relayCalls - 1]!.promise; | |
| 854 | + } finally { | |
| 855 | + relayActive -= 1; | |
| 856 | + } | |
| 857 | + }, { limiter, metrics, clock: () => now }); | |
| 858 | + | |
| 859 | + const first = resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }); | |
| 860 | + await flush(); | |
| 861 | + assert.equal(relayCalls, 1); | |
| 862 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 1); | |
| 863 | + | |
| 864 | + const second = resolver({ serviceId: BUS_SERVICE, apiKey: KEY_B }); | |
| 865 | + await flush(); | |
| 866 | + assert.equal(relayCalls, 1); | |
| 867 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 1); | |
| 868 | + | |
| 869 | + now += 5_000; | |
| 870 | + gates[0]!.resolve(response); | |
| 871 | + const firstResult = await first; | |
| 872 | + assert.equal( | |
| 873 | + isDepartureResult({ ...firstResult, requestId: "probe", favoriteId: "probe" }), | |
| 874 | + true, | |
| 875 | + ); | |
| 876 | + await flush(); | |
| 877 | + assert.equal(relayCalls, 2); | |
| 878 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 2); | |
| 879 | + | |
| 880 | + now += 25; | |
| 881 | + gates[1]!.resolve(response); | |
| 882 | + const secondResult = await second; | |
| 883 | + assert.equal( | |
| 884 | + isDepartureResult({ ...secondResult, requestId: "probe", favoriteId: "probe" }), | |
| 885 | + true, | |
| 886 | + ); | |
| 887 | + assert.equal(relayPeak, 1); | |
| 888 | + assert.deepEqual( | |
| 889 | + metrics.entries | |
| 890 | + .filter((entry) => entry.name === DEPARTURE_METRIC_NAME.UPSTREAM_STATUS) | |
| 891 | + .map((entry) => entry.value), | |
| 892 | + [200, 200], | |
| 893 | + ); | |
| 894 | + assert.deepEqual( | |
| 895 | + metrics.entries | |
| 896 | + .filter((entry) => entry.name === DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS) | |
| 897 | + .map((entry) => entry.value), | |
| 898 | + [5_000, 25], | |
| 899 | + ); | |
| 900 | +}); | |
| 901 | + | |
| 902 | +test("the default process limiter caps concurrent upstream calls at eight", async () => { | |
| 903 | + const gates = Array.from({ length: 9 }, () => Promise.withResolvers<PrimDepartureRelayResponse>()); | |
| 904 | + let relayCalls = 0; | |
| 905 | + let relayPeak = 0; | |
| 906 | + let relayActive = 0; | |
| 907 | + const resolver = busResolver(async () => { | |
| 908 | + relayCalls += 1; | |
| 909 | + relayActive += 1; | |
| 910 | + relayPeak = Math.max(relayPeak, relayActive); | |
| 911 | + try { | |
| 912 | + return await gates[relayCalls - 1]!.promise; | |
| 913 | + } finally { | |
| 914 | + relayActive -= 1; | |
| 915 | + } | |
| 916 | + }); | |
| 917 | + | |
| 918 | + const results = Array.from( | |
| 919 | + { length: 9 }, | |
| 920 | + () => resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | |
| 921 | + ); | |
| 922 | + await flush(); | |
| 923 | + assert.equal(relayCalls, 8); | |
| 924 | + assert.equal(relayPeak, 8); | |
| 925 | + | |
| 926 | + const payload = relayJson(await loadFixture("bus")); | |
| 927 | + for (const gate of gates) gate.resolve(payload); | |
| 928 | + await Promise.all(results); | |
| 929 | + assert.equal(relayPeak, 8); | |
| 930 | +}); | |
| 931 | + | |
| 932 | +test("unknown services settle INVALID_SERVICE at the handler without caching or upstream work", async () => { | |
| 933 | + const state = createDepartureRuntimeState(); | |
| 934 | + const { seen, resolve } = recordingResolver(async () => errorSnapshot("INVALID_SERVICE", CLOCK_SEC)); | |
| 935 | + const first = expectError(await requestDepartures({ | |
| 936 | + serviceId: "ghost-service", | |
| 937 | + requestId: "ghost-1", | |
| 938 | + state, | |
| 939 | + resolve, | |
| 940 | + nowMilliseconds: () => CLOCK_MS, | |
| 941 | + })); | |
| 942 | + assert.deepEqual(first, { | |
| 943 | + schemaVersion: SCHEMA_VERSION, | |
| 944 | + requestId: "ghost-1", | |
| 945 | + favoriteId: "dep-favorite", | |
| 946 | + code: "INVALID_SERVICE", | |
| 947 | + occurredAt: CLOCK_SEC, | |
| 948 | + }); | |
| 949 | + assert.equal(seen.length, 1); | |
| 950 | + assert.equal(state.cache.size, 0); | |
| 951 | + | |
| 952 | + await requestDepartures({ serviceId: "ghost-service", state, resolve }); | |
| 953 | + assert.equal(seen.length, 2); | |
| 954 | +}); | |
| 955 | + | |
| 956 | +test("cache is service-keyed, credential-free, and fresh for exactly 60 seconds with source timestamps retained", async () => { | |
| 957 | + const state = createDepartureRuntimeState(); | |
| 958 | + let now = CLOCK_MS; | |
| 959 | + let resolverCalls = 0; | |
| 960 | + const snapshots = [snapshot(CLOCK_SEC), snapshot(CLOCK_SEC + 60), snapshot(CLOCK_SEC + 120)]; | |
| 961 | + const resolve = async () => { | |
| 962 | + resolverCalls += 1; | |
| 963 | + return snapshots[resolverCalls - 1]!; | |
| 964 | + }; | |
| 965 | + const call = (requestId: string, apiKey: string) => | |
| 966 | + requestDepartures({ | |
| 967 | + serviceId: "cache-service", | |
| 968 | + requestId, | |
| 969 | + apiKey, | |
| 970 | + state, | |
| 971 | + resolve, | |
| 972 | + nowMilliseconds: () => now, | |
| 973 | + }); | |
| 974 | + | |
| 975 | + const initial = expectDeparture(await call("cache-1", KEY_A)); | |
| 976 | + assert.equal(initial.fetchedAt, CLOCK_SEC); | |
| 977 | + assert.equal(initial.sourceUpdatedAt, CLOCK_SEC - 10); | |
| 978 | + assert.deepEqual([...state.cache.keys()], ["cache-service"]); | |
| 979 | + assert.equal(JSON.stringify([...state.cache.values()]).includes(KEY_A), false); | |
| 980 | + assert.equal(JSON.stringify([...state.cache.values()]).includes(KEY_B), false); | |
| 981 | + | |
| 982 | + now = CLOCK_MS + CACHE_FRESH_SECONDS * 1_000 - 1; | |
| 983 | + const fresh = expectDeparture(await call("cache-2", KEY_B)); | |
| 984 | + assert.equal(resolverCalls, 1); | |
| 985 | + assert.equal(fresh.fetchedAt, CLOCK_SEC); | |
| 986 | + assert.equal(fresh.sourceUpdatedAt, CLOCK_SEC - 10); | |
| 987 | + | |
| 988 | + now = CLOCK_MS + CACHE_FRESH_SECONDS * 1_000; | |
| 989 | + const expired = expectDeparture(await call("cache-3", KEY_B)); | |
| 990 | + assert.equal(resolverCalls, 2); | |
| 991 | + assert.equal(expired.fetchedAt, CLOCK_SEC + 60); | |
| 992 | + | |
| 993 | + now = CLOCK_MS; | |
| 994 | + const backwards = expectDeparture(await call("cache-4", KEY_B)); | |
| 995 | + assert.equal(resolverCalls, 3); | |
| 996 | + assert.equal(backwards.fetchedAt, CLOCK_SEC + 120); | |
| 997 | +}); | |
| 998 | + | |
| 999 | +test("cache hits accept any syntactically valid non-empty key and never validate upstream", async () => { | |
| 1000 | + const state = createDepartureRuntimeState(); | |
| 1001 | + const metrics = metricsRecorder(); | |
| 1002 | + let resolverCalls = 0; | |
| 1003 | + const resolve = async () => { | |
| 1004 | + resolverCalls += 1; | |
| 1005 | + if (resolverCalls === 1) return snapshot(CLOCK_SEC); | |
| 1006 | + throw new Error(`upstream must not run for cache hits (${KEY_A})`); | |
| 1007 | + }; | |
| 1008 | + await requestDepartures({ | |
| 1009 | + serviceId: "warm-service", | |
| 1010 | + apiKey: KEY_A, | |
| 1011 | + state, | |
| 1012 | + resolve, | |
| 1013 | + nowMilliseconds: () => CLOCK_MS, | |
| 1014 | + }); | |
| 1015 | + | |
| 1016 | + const hit = expectDeparture(await requestDepartures({ | |
| 1017 | + serviceId: "warm-service", | |
| 1018 | + requestId: "warm-2", | |
| 1019 | + apiKey: "x", | |
| 1020 | + state, | |
| 1021 | + resolve, | |
| 1022 | + nowMilliseconds: () => CLOCK_MS, | |
| 1023 | + metrics, | |
| 1024 | + })); | |
| 1025 | + assert.equal(hit.requestId, "warm-2"); | |
| 1026 | + assert.deepEqual(metrics.entries.map((entry) => entry.name).sort(), [ | |
| 1027 | + DEPARTURE_METRIC_NAME.CACHE_HIT, | |
| 1028 | + DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES, | |
| 1029 | + ]); | |
| 1030 | + const cached = state.cache.get("warm-service")!; | |
| 1031 | + assert.equal( | |
| 1032 | + metrics.entries.find((entry) => entry.name === DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES)?.value, | |
| 1033 | + utf8Bytes(JSON.stringify(cached.data)), | |
| 1034 | + ); | |
| 1035 | +}); | |
| 1036 | + | |
| 1037 | +test("cache hits return fresh per-caller bindings and defensive copies", async () => { | |
| 1038 | + const state = createDepartureRuntimeState(); | |
| 1039 | + const resolve = async () => snapshot(CLOCK_SEC); | |
| 1040 | + await requestDepartures({ | |
| 1041 | + serviceId: "bind-service", | |
| 1042 | + apiKey: KEY_A, | |
| 1043 | + state, | |
| 1044 | + resolve, | |
| 1045 | + nowMilliseconds: () => CLOCK_MS, | |
| 1046 | + }); | |
| 1047 | + | |
| 1048 | + const first = expectDeparture(await requestDepartures({ | |
| 1049 | + serviceId: "bind-service", | |
| 1050 | + requestId: "bind-1", | |
| 1051 | + favoriteId: "fav-1", | |
| 1052 | + apiKey: KEY_A, | |
| 1053 | + state, | |
| 1054 | + resolve, | |
| 1055 | + nowMilliseconds: () => CLOCK_MS, | |
| 1056 | + })); | |
| 1057 | + const second = expectDeparture(await requestDepartures({ | |
| 1058 | + serviceId: "bind-service", | |
| 1059 | + requestId: "bind-2", | |
| 1060 | + favoriteId: "fav-2", | |
| 1061 | + apiKey: KEY_B, | |
| 1062 | + state, | |
| 1063 | + resolve, | |
| 1064 | + nowMilliseconds: () => CLOCK_MS, | |
| 1065 | + })); | |
| 1066 | + | |
| 1067 | + assert.equal(first.requestId, "bind-1"); | |
| 1068 | + assert.equal(first.favoriteId, "fav-1"); | |
| 1069 | + assert.equal(second.requestId, "bind-2"); | |
| 1070 | + assert.equal(second.favoriteId, "fav-2"); | |
| 1071 | + assert.notEqual(first.departures, second.departures); | |
| 1072 | + | |
| 1073 | + first.departures[0]!.minutes = 999; | |
| 1074 | + first.departures.push({ | |
| 1075 | + expectedAt: CLOCK_SEC + 999, | |
| 1076 | + minutes: 999, | |
| 1077 | + status: "CANCELLED", | |
| 1078 | + }); | |
| 1079 | + assert.equal(second.departures[0]!.minutes, 2); | |
| 1080 | + assert.equal(second.departures.length, 2); | |
| 1081 | + assert.equal(state.cache.get("bind-service")!.data.departures[0]!.minutes, 2); | |
| 1082 | +}); | |
| 1083 | + | |
| 1084 | +test("concurrent misses on one service single-flight upstream and bind every caller", async () => { | |
| 1085 | + const state = createDepartureRuntimeState(); | |
| 1086 | + const metrics = metricsRecorder(); | |
| 1087 | + const gate = Promise.withResolvers<PublicDepartureData>(); | |
| 1088 | + const { seen, resolve } = recordingResolver(async () => gate.promise); | |
| 1089 | + | |
| 1090 | + const calls = [KEY_A, KEY_B, KEY_C].map((apiKey, index) => | |
| 1091 | + requestDepartures({ | |
| 1092 | + serviceId: "flight-service", | |
| 1093 | + requestId: `flight-${index}`, | |
| 1094 | + favoriteId: `flight-fav-${index}`, | |
| 1095 | + apiKey, | |
| 1096 | + state, | |
| 1097 | + resolve, | |
| 1098 | + nowMilliseconds: () => CLOCK_MS, | |
| 1099 | + metrics, | |
| 1100 | + })); | |
| 1101 | + | |
| 1102 | + assert.equal(seen.length, 1); | |
| 1103 | + assert.deepEqual(seen[0], { serviceId: "flight-service", apiKey: KEY_A }); | |
| 1104 | + assert.equal(state.flights.get("flight-service")?.waiters.length, 2); | |
| 1105 | + | |
| 1106 | + gate.resolve(snapshot(CLOCK_SEC)); | |
| 1107 | + const results = await Promise.all(calls); | |
| 1108 | + results.forEach((result, index) => { | |
| 1109 | + const departure = expectDeparture(result); | |
| 1110 | + assert.equal(departure.requestId, `flight-${index}`); | |
| 1111 | + assert.equal(departure.favoriteId, `flight-fav-${index}`); | |
| 1112 | + assert.equal(departure.fetchedAt, CLOCK_SEC); | |
| 1113 | + }); | |
| 1114 | + assert.equal(seen.length, 1); | |
| 1115 | + assert.equal(state.flights.size, 0); | |
| 1116 | + assert.deepEqual([...state.cache.keys()], ["flight-service"]); | |
| 1117 | + assert.equal(JSON.stringify(results).includes(KEY_A), false); | |
| 1118 | + assert.equal(JSON.stringify(results).includes(KEY_C), false); | |
| 1119 | + | |
| 1120 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.CACHE_MISS), 3); | |
| 1121 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.COALESCED_REQUEST), 2); | |
| 1122 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 0); | |
| 1123 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_STATUS), 0); | |
| 1124 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS), 0); | |
| 1125 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES), 3); | |
| 1126 | + for (const entry of metrics.entries) { | |
| 1127 | + assert.ok(Object.values(DEPARTURE_METRIC_NAME).includes(entry.name)); | |
| 1128 | + assert.equal(JSON.stringify(entry).includes(KEY_A), false); | |
| 1129 | + } | |
| 1130 | +}); | |
| 1131 | + | |
| 1132 | +test("a same-key credential failure settles the whole in-flight queue from one upstream call", async () => { | |
| 1133 | + const state = createDepartureRuntimeState(); | |
| 1134 | + const gate = Promise.withResolvers<PublicDepartureError>(); | |
| 1135 | + const { seen, resolve } = recordingResolver(async () => gate.promise); | |
| 1136 | + | |
| 1137 | + const calls = [0, 1].map((index) => | |
| 1138 | + requestDepartures({ | |
| 1139 | + serviceId: "invalid-key-service", | |
| 1140 | + requestId: `invalid-${index}`, | |
| 1141 | + favoriteId: `invalid-fav-${index}`, | |
| 1142 | + apiKey: KEY_A, | |
| 1143 | + state, | |
| 1144 | + resolve, | |
| 1145 | + nowMilliseconds: () => CLOCK_MS, | |
| 1146 | + })); | |
| 1147 | + | |
| 1148 | + gate.resolve(errorSnapshot("API_KEY_INVALID", CLOCK_SEC, 120)); | |
| 1149 | + const results = await Promise.all(calls); | |
| 1150 | + results.forEach((result, index) => { | |
| 1151 | + assert.deepEqual(result, { | |
| 1152 | + schemaVersion: SCHEMA_VERSION, | |
| 1153 | + requestId: `invalid-${index}`, | |
| 1154 | + favoriteId: `invalid-fav-${index}`, | |
| 1155 | + code: "API_KEY_INVALID", | |
| 1156 | + occurredAt: CLOCK_SEC, | |
| 1157 | + retryAfterSeconds: 120, | |
| 1158 | + }); | |
| 1159 | + }); | |
| 1160 | + assert.equal(seen.length, 1); | |
| 1161 | + assert.equal(state.flights.size, 0); | |
| 1162 | + assert.equal(state.cache.size, 0); | |
| 1163 | + | |
| 1164 | + const again = expectError(await requestDepartures({ | |
| 1165 | + serviceId: "invalid-key-service", | |
| 1166 | + requestId: "invalid-2", | |
| 1167 | + apiKey: KEY_A, | |
| 1168 | + state, | |
| 1169 | + resolve, | |
| 1170 | + nowMilliseconds: () => CLOCK_MS, | |
| 1171 | + })); | |
| 1172 | + assert.equal(again.code, "API_KEY_INVALID"); | |
| 1173 | + assert.equal(seen.length, 2); | |
| 1174 | +}); | |
| 1175 | + | |
| 1176 | +test("mixed-key failures settle failed-key waiters and promote exactly one different-key leader without burst", async () => { | |
| 1177 | + const state = createDepartureRuntimeState(); | |
| 1178 | + const metrics = metricsRecorder(); | |
| 1179 | + const gates: Array<PromiseWithResolvers<DepartureResolverResult>> = []; | |
| 1180 | + const { seen, resolve } = recordingResolver(async () => { | |
| 1181 | + const gate = Promise.withResolvers<DepartureResolverResult>(); | |
| 1182 | + gates.push(gate); | |
| 1183 | + return gate.promise; | |
| 1184 | + }); | |
| 1185 | + | |
| 1186 | + const calls = [ | |
| 1187 | + { apiKey: KEY_A, requestId: "mix-a1", favoriteId: "mix-fav-a1" }, | |
| 1188 | + { apiKey: KEY_A, requestId: "mix-a2", favoriteId: "mix-fav-a2" }, | |
| 1189 | + { apiKey: KEY_B, requestId: "mix-b", favoriteId: "mix-fav-b" }, | |
| 1190 | + { apiKey: KEY_C, requestId: "mix-c", favoriteId: "mix-fav-c" }, | |
| 1191 | + ].map((input) => | |
| 1192 | + requestDepartures({ | |
| 1193 | + serviceId: "mixed-service", | |
| 1194 | + ...input, | |
| 1195 | + state, | |
| 1196 | + resolve, | |
| 1197 | + nowMilliseconds: () => CLOCK_MS, | |
| 1198 | + metrics, | |
| 1199 | + })); | |
| 1200 | + | |
| 1201 | + assert.equal(seen.length, 1); | |
| 1202 | + gates[0]!.resolve(errorSnapshot("API_KEY_INVALID", CLOCK_SEC)); | |
| 1203 | + const [failedA1, failedA2] = await Promise.all(calls.slice(0, 2)); | |
| 1204 | + | |
| 1205 | + assert.deepEqual(failedA1, { | |
| 1206 | + schemaVersion: SCHEMA_VERSION, | |
| 1207 | + requestId: "mix-a1", | |
| 1208 | + favoriteId: "mix-fav-a1", | |
| 1209 | + code: "API_KEY_INVALID", | |
| 1210 | + occurredAt: CLOCK_SEC, | |
| 1211 | + }); | |
| 1212 | + assert.deepEqual(failedA2, { | |
| 1213 | + schemaVersion: SCHEMA_VERSION, | |
| 1214 | + requestId: "mix-a2", | |
| 1215 | + favoriteId: "mix-fav-a2", | |
| 1216 | + code: "API_KEY_INVALID", | |
| 1217 | + occurredAt: CLOCK_SEC, | |
| 1218 | + }); | |
| 1219 | + assert.equal(seen.length, 2); | |
| 1220 | + assert.deepEqual(seen[1], { serviceId: "mixed-service", apiKey: KEY_B }); | |
| 1221 | + assert.notEqual(state.flights.get("mixed-service"), undefined); | |
| 1222 | + | |
| 1223 | + gates[1]!.resolve(snapshot(CLOCK_SEC)); | |
| 1224 | + const [promoted, coalesced] = await Promise.all(calls.slice(2)); | |
| 1225 | + const promotedData = expectDeparture(promoted!); | |
| 1226 | + const coalescedData = expectDeparture(coalesced!); | |
| 1227 | + assert.equal(promotedData.requestId, "mix-b"); | |
| 1228 | + assert.equal(promotedData.fetchedAt, CLOCK_SEC); | |
| 1229 | + assert.equal(coalescedData.requestId, "mix-c"); | |
| 1230 | + assert.equal(coalescedData.favoriteId, "mix-fav-c"); | |
| 1231 | + assert.equal(coalescedData.fetchedAt, CLOCK_SEC); | |
| 1232 | + | |
| 1233 | + assert.equal(seen.length, 2); | |
| 1234 | + assert.equal(seen.filter((request) => request.apiKey === KEY_A).length, 1); | |
| 1235 | + assert.equal(state.flights.size, 0); | |
| 1236 | + assert.deepEqual([...state.cache.keys()], ["mixed-service"]); | |
| 1237 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.COALESCED_REQUEST), 3); | |
| 1238 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 0); | |
| 1239 | +}); | |
| 1240 | + | |
| 1241 | +test("shared upstream failures settle every waiter with its own bound error and never cache", async () => { | |
| 1242 | + const state = createDepartureRuntimeState(); | |
| 1243 | + const { seen, resolve } = recordingResolver(async () => { | |
| 1244 | + throw new Error(`upstream socket lost (${KEY_B})`); | |
| 1245 | + }); | |
| 1246 | + const keys = [KEY_A, KEY_B, KEY_C]; | |
| 1247 | + const results = await Promise.all(keys.map((apiKey, index) => | |
| 1248 | + requestDepartures({ | |
| 1249 | + serviceId: "down-service", | |
| 1250 | + requestId: `down-${index}`, | |
| 1251 | + apiKey, | |
| 1252 | + state, | |
| 1253 | + resolve, | |
| 1254 | + nowMilliseconds: () => CLOCK_MS, | |
| 1255 | + }))); | |
| 1256 | + results.forEach((result, index) => { | |
| 1257 | + assert.deepEqual(result, { | |
| 1258 | + schemaVersion: SCHEMA_VERSION, | |
| 1259 | + requestId: `down-${index}`, | |
| 1260 | + favoriteId: "dep-favorite", | |
| 1261 | + code: "SOURCE_UNAVAILABLE", | |
| 1262 | + occurredAt: CLOCK_SEC, | |
| 1263 | + }); | |
| 1264 | + }); | |
| 1265 | + assert.equal(seen.length, 1); | |
| 1266 | + assert.equal(state.cache.size, 0); | |
| 1267 | + assert.equal(state.flights.size, 0); | |
| 1268 | + assert.equal(JSON.stringify(results).includes(KEY_B), false); | |
| 1269 | + | |
| 1270 | + const junkState = createDepartureRuntimeState(); | |
| 1271 | + const junk = expectError(await requestDepartures({ | |
| 1272 | + serviceId: "junk-service", | |
| 1273 | + requestId: "junk-1", | |
| 1274 | + apiKey: KEY_A, | |
| 1275 | + state: junkState, | |
| 1276 | + resolve: async () => ({ unexpected: true }) as unknown as PublicDepartureData, | |
| 1277 | + nowMilliseconds: () => CLOCK_MS, | |
| 1278 | + })); | |
| 1279 | + assert.equal(junk.code, "INVALID_RESPONSE"); | |
| 1280 | + assert.equal(junkState.cache.size, 0); | |
| 1281 | +}); | |
| 1282 | + | |
| 1283 | +test("departure HTTP boundary maps methods, paths, and every error code to frozen statuses", async () => { | |
| 1284 | + const route = DEPARTURE_ROUTE; | |
| 1285 | + const base = { | |
| 1286 | + state: createDepartureRuntimeState(), | |
| 1287 | + resolve: async () => snapshot(CLOCK_SEC), | |
| 1288 | + nowMilliseconds: () => CLOCK_MS, | |
| 1289 | + }; | |
| 1290 | + const post = (overrides: { | |
| 1291 | + readonly method?: string; | |
| 1292 | + readonly path?: string; | |
| 1293 | + readonly authorization?: unknown; | |
| 1294 | + readonly body?: unknown; | |
| 1295 | + }) => handleDepartureHttpRequest({ | |
| 1296 | + method: overrides.method ?? "POST", | |
| 1297 | + path: overrides.path ?? route, | |
| 1298 | + authorization: overrides.authorization, | |
| 1299 | + body: overrides.body ?? { | |
| 1300 | + schemaVersion: SCHEMA_VERSION, | |
| 1301 | + requestId: "http-ok", | |
| 1302 | + favoriteId: "http-fav-ok", | |
| 1303 | + serviceId: "http-service", | |
| 1304 | + }, | |
| 1305 | + }, base); | |
| 1306 | + | |
| 1307 | + const success = await post({ authorization: `Bearer ${KEY_A}` }); | |
| 1308 | + assert.equal(success.status, 200); | |
| 1309 | + assert.equal(isDepartureResult(success.body), true); | |
| 1310 | + | |
| 1311 | + const methodMismatch = await post({ method: "GET" }); | |
| 1312 | + assert.equal(methodMismatch.status, 405); | |
| 1313 | + assert.equal(expectError(methodMismatch.body).code, "INVALID_SERVICE"); | |
| 1314 | + | |
| 1315 | + const pathMismatch = await post({ path: "/api/departures/extra" }); | |
| 1316 | + assert.equal(pathMismatch.status, 404); | |
| 1317 | + | |
| 1318 | + const errorCases: ReadonlyArray<{ | |
| 1319 | + readonly expectedStatus: 400 | 401 | 429 | 502 | 503; | |
| 1320 | + readonly expectedCode: ErrorCode; | |
| 1321 | + readonly authorization?: unknown; | |
| 1322 | + readonly body?: unknown; | |
| 1323 | + readonly resolve: (request: DepartureResolverRequest) => Promise<DepartureResolverResult>; | |
| 1324 | + }> = [ | |
| 1325 | + { | |
| 1326 | + expectedStatus: 401, | |
| 1327 | + expectedCode: "API_KEY_REQUIRED", | |
| 1328 | + resolve: base.resolve, | |
| 1329 | + }, | |
| 1330 | + { | |
| 1331 | + expectedStatus: 401, | |
| 1332 | + expectedCode: "API_KEY_INVALID", | |
| 1333 | + authorization: `Token ${KEY_A}`, | |
| 1334 | + resolve: base.resolve, | |
| 1335 | + }, | |
| 1336 | + { | |
| 1337 | + expectedStatus: 400, | |
| 1338 | + expectedCode: "INVALID_SERVICE", | |
| 1339 | + authorization: `Bearer ${KEY_A}`, | |
| 1340 | + body: { schemaVersion: SCHEMA_VERSION }, | |
| 1341 | + resolve: base.resolve, | |
| 1342 | + }, | |
| 1343 | + { | |
| 1344 | + expectedStatus: 429, | |
| 1345 | + expectedCode: "RATE_LIMITED", | |
| 1346 | + authorization: `Bearer ${KEY_A}`, | |
| 1347 | + resolve: async () => errorSnapshot("RATE_LIMITED", CLOCK_SEC, 30), | |
| 1348 | + }, | |
| 1349 | + { | |
| 1350 | + expectedStatus: 503, | |
| 1351 | + expectedCode: "SOURCE_UNAVAILABLE", | |
| 1352 | + authorization: `Bearer ${KEY_A}`, | |
| 1353 | + resolve: async () => { | |
| 1354 | + throw new Error("downstream gone"); | |
| 1355 | + }, | |
| 1356 | + }, | |
| 1357 | + { | |
| 1358 | + expectedStatus: 502, | |
| 1359 | + expectedCode: "INVALID_RESPONSE", | |
| 1360 | + authorization: `Bearer ${KEY_A}`, | |
| 1361 | + resolve: async () => ({ junk: true }) as unknown as PublicDepartureData, | |
| 1362 | + }, | |
| 1363 | + ]; | |
| 1364 | + for (const [index, errorCase] of errorCases.entries()) { | |
| 1365 | + const response = await handleDepartureHttpRequest({ | |
| 1366 | + method: "POST", | |
| 1367 | + path: route, | |
| 1368 | + authorization: errorCase.authorization, | |
| 1369 | + body: errorCase.body ?? { | |
| 1370 | + schemaVersion: SCHEMA_VERSION, | |
| 1371 | + requestId: `http-err-${index}`, | |
| 1372 | + favoriteId: `http-fav-${index}`, | |
| 1373 | + serviceId: `http-err-service-${index}`, | |
| 1374 | + }, | |
| 1375 | + }, { | |
| 1376 | + state: createDepartureRuntimeState(), | |
| 1377 | + resolve: errorCase.resolve, | |
| 1378 | + nowMilliseconds: () => CLOCK_MS, | |
| 1379 | + }); | |
| 1380 | + assert.equal(response.status, errorCase.expectedStatus, `case ${errorCase.expectedCode}`); | |
| 1381 | + const body = expectError(response.body); | |
| 1382 | + assert.equal(body.code, errorCase.expectedCode); | |
| 1383 | + if (errorCase.expectedCode === "RATE_LIMITED") { | |
| 1384 | + assert.equal(body.retryAfterSeconds, 30); | |
| 1385 | + } | |
| 1386 | + assert.equal(JSON.stringify(body).includes(KEY_A), false); | |
| 1387 | + } | |
| 1388 | + | |
| 1389 | + const rateLimited = expectError((await handleDepartureHttpRequest({ | |
| 1390 | + method: "POST", | |
| 1391 | + path: route, | |
| 1392 | + authorization: `Bearer ${KEY_A}`, | |
| 1393 | + body: { | |
| 1394 | + schemaVersion: SCHEMA_VERSION, | |
| 1395 | + requestId: "http-429", | |
| 1396 | + favoriteId: "http-fav-429", | |
| 1397 | + serviceId: "http-429-service", | |
| 1398 | + }, | |
| 1399 | + }, { | |
| 1400 | + state: createDepartureRuntimeState(), | |
| 1401 | + resolve: async () => errorSnapshot("RATE_LIMITED", CLOCK_SEC, 30), | |
| 1402 | + nowMilliseconds: () => CLOCK_MS, | |
| 1403 | + })).body); | |
| 1404 | + assert.equal(departureHttpStatus(rateLimited), 429); | |
| 1405 | +}); | |
| 1406 | + | |
| 1407 | +test("representative cache-hit loop keeps p95 backend latency at or below 200 ms", async () => { | |
| 1408 | + const state = createDepartureRuntimeState(); | |
| 1409 | + let resolverCalls = 0; | |
| 1410 | + const resolve = async () => { | |
| 1411 | + resolverCalls += 1; | |
| 1412 | + if (resolverCalls === 1) return snapshot(CLOCK_SEC); | |
| 1413 | + throw new Error("upstream must never run in the cache-hit loop"); | |
| 1414 | + }; | |
| 1415 | + expectDeparture(await requestDepartures({ | |
| 1416 | + serviceId: "p95-service", | |
| 1417 | + requestId: "p95-warm", | |
| 1418 | + apiKey: KEY_A, | |
| 1419 | + state, | |
| 1420 | + resolve, | |
| 1421 | + })); | |
| 1422 | + | |
| 1423 | + const durations: number[] = []; | |
| 1424 | + for (let index = 0; index < 1000; index += 1) { | |
| 1425 | + const startedAt = performance.now(); | |
| 1426 | + const result = await requestDepartures({ | |
| 1427 | + serviceId: "p95-service", | |
| 1428 | + requestId: `p95-${index}`, | |
| 1429 | + apiKey: KEY_A, | |
| 1430 | + state, | |
| 1431 | + resolve, | |
| 1432 | + }); | |
| 1433 | + durations.push(performance.now() - startedAt); | |
| 1434 | + expectDeparture(result); | |
| 1435 | + } | |
| 1436 | + durations.sort((left, right) => left - right); | |
| 1437 | + const p95 = durations[Math.ceil(durations.length * 0.95) - 1]!; | |
| 1438 | + assert.ok( | |
| 1439 | + p95 <= 200, | |
| 1440 | + `cache-hit p95 ${p95.toFixed(3)}ms exceeds the 200ms budget`, | |
| 1441 | + ); | |
| 1442 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,1442 @@ | |||
| 1 | +import assert from "node:assert/strict"; | ||
| 2 | +import { readFile } from "node:fs/promises"; | ||
| 3 | +import test from "node:test"; | ||
| 4 | +import { performance } from "node:perf_hooks"; | ||
| 5 | +import { | ||
| 6 | + CACHE_FRESH_SECONDS, | ||
| 7 | + LIMITS, | ||
| 8 | + PRIM_ORIGIN, | ||
| 9 | + SCHEMA_VERSION, | ||
| 10 | + isDepartureResult, | ||
| 11 | + isErrorResult, | ||
| 12 | + utf8Bytes, | ||
| 13 | + type Departure, | ||
| 14 | + type DepartureResult, | ||
| 15 | + type DepartureStatus, | ||
| 16 | + type ErrorCode, | ||
| 17 | + type ErrorResult, | ||
| 18 | + type Freshness, | ||
| 19 | + type TransportMode, | ||
| 20 | +} from "../../contracts/src/index.ts"; | ||
| 21 | +import { | ||
| 22 | + DEPARTURE_METRIC_NAME, | ||
| 23 | + DEPARTURE_ROUTE, | ||
| 24 | + createDepartureRuntimeState, | ||
| 25 | + departureHttpStatus, | ||
| 26 | + handleDepartureHttpRequest, | ||
| 27 | + handleDepartureRequest, | ||
| 28 | + type DepartureHandlerResponse, | ||
| 29 | + type DepartureMetrics, | ||
| 30 | + type DepartureResolverRequest, | ||
| 31 | + type DepartureResolverResult, | ||
| 32 | + type DepartureRuntimeState, | ||
| 33 | + type PublicDepartureData, | ||
| 34 | + type PublicDepartureError, | ||
| 35 | +} from "../src/departures.ts"; | ||
| 36 | +import { | ||
| 37 | + PrimNormalizationError, | ||
| 38 | + buildPrimStopMonitoringUrl, | ||
| 39 | + normalizePrimDepartureResponse, | ||
| 40 | + parseRetryAfterSeconds, | ||
| 41 | + type PrimNormalizedDepartureData, | ||
| 42 | + type PrimServiceResolution, | ||
| 43 | +} from "../src/prim-departures.ts"; | ||
| 44 | +import { | ||
| 45 | + createPrimDepartureResolver, | ||
| 46 | + createUpstreamLimiter, | ||
| 47 | + type PrimDepartureRelay, | ||
| 48 | + type PrimDepartureRelayResponse, | ||
| 49 | + type UpstreamLimiter, | ||
| 50 | +} from "../src/departure-service.ts"; | ||
| 51 | +import type { CatalogReader, CatalogServiceResolution } from "../src/catalog.ts"; | ||
| 52 | +import { | ||
| 53 | + relayPrimRequest, | ||
| 54 | + type PrimFetch, | ||
| 55 | + type PrimFetchInit, | ||
| 56 | +} from "../src/index.ts"; | ||
| 57 | + | ||
| 58 | +const encoder = new TextEncoder(); | ||
| 59 | +const KEY_A = "test-personal-key-a"; | ||
| 60 | +const KEY_B = "test-personal-key-b"; | ||
| 61 | +const KEY_C = "test-personal-key-c"; | ||
| 62 | +const CLOCK_MS = Date.parse("2026-01-15T09:00:00Z"); | ||
| 63 | +const CLOCK_SEC = CLOCK_MS / 1000; | ||
| 64 | +const FETCHED_AT = Date.parse("2026-01-15T08:30:00Z") / 1000; | ||
| 65 | + | ||
| 66 | +const sec = (iso: string): number => Date.parse(iso) / 1000; | ||
| 67 | + | ||
| 68 | +const RESOLUTIONS = { | ||
| 69 | + BUS: { | ||
| 70 | + monitoringRef: "IDFM:SP:1001", | ||
| 71 | + lineRef: "IDFM:C1001", | ||
| 72 | + directionId: "IDFM:1001D", | ||
| 73 | + destinationRef: "IDFM:1001DST", | ||
| 74 | + }, | ||
| 75 | + METRO: { | ||
| 76 | + monitoringRef: "IDFM:SP:2001", | ||
| 77 | + lineRef: "IDFM:C2001", | ||
| 78 | + directionId: "IDFM:2001D", | ||
| 79 | + destinationRef: "IDFM:2001DST", | ||
| 80 | + }, | ||
| 81 | + TRAM: { | ||
| 82 | + monitoringRef: "IDFM:SP:3001", | ||
| 83 | + lineRef: "IDFM:C3001", | ||
| 84 | + directionId: "IDFM:3001D", | ||
| 85 | + destinationRef: "IDFM:3001DST", | ||
| 86 | + }, | ||
| 87 | + RER: { | ||
| 88 | + monitoringRef: "IDFM:SP:4001", | ||
| 89 | + lineRef: "IDFM:C4001", | ||
| 90 | + directionId: "IDFM:4001D", | ||
| 91 | + destinationRef: "IDFM:4001DST", | ||
| 92 | + }, | ||
| 93 | + TRANSILIEN: { | ||
| 94 | + monitoringRef: "IDFM:SP:5001", | ||
| 95 | + lineRef: "IDFM:C5001", | ||
| 96 | + directionId: "IDFM:5001D", | ||
| 97 | + destinationRef: "IDFM:5001DST", | ||
| 98 | + }, | ||
| 99 | +} as const satisfies Record<TransportMode, PrimServiceResolution>; | ||
| 100 | + | ||
| 101 | +function expectedDeparture( | ||
| 102 | + expectedIso: string, | ||
| 103 | + minutes: number, | ||
| 104 | + status: DepartureStatus, | ||
| 105 | + options: { aimedIso?: string; interval?: number } = {}, | ||
| 106 | +): Departure { | ||
| 107 | + const departure: Departure = { expectedAt: sec(expectedIso), minutes, status }; | ||
| 108 | + if (options.aimedIso !== undefined) departure.aimedAt = sec(options.aimedIso); | ||
| 109 | + if (options.interval !== undefined) departure.nextIntervalMinutes = options.interval; | ||
| 110 | + return departure; | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +function expectedNormalized( | ||
| 114 | + freshness: Freshness, | ||
| 115 | + departures: Departure[], | ||
| 116 | + sourceUpdatedAt: number = FETCHED_AT, | ||
| 117 | +): PrimNormalizedDepartureData { | ||
| 118 | + return { schemaVersion: SCHEMA_VERSION, fetchedAt: FETCHED_AT, sourceUpdatedAt, freshness, departures }; | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +const FIXTURE_CACHE = new Map<string, unknown>(); | ||
| 122 | + | ||
| 123 | +async function loadFixture(name: string): Promise<unknown> { | ||
| 124 | + const cached = FIXTURE_CACHE.get(name); | ||
| 125 | + if (cached !== undefined) return cached; | ||
| 126 | + const parsed: unknown = JSON.parse( | ||
| 127 | + await readFile(new URL(`../../../fixtures/departures/prim/${name}.json`, import.meta.url), "utf8"), | ||
| 128 | + ); | ||
| 129 | + FIXTURE_CACHE.set(name, parsed); | ||
| 130 | + return parsed; | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +const MODE_FIXTURES: ReadonlyArray<{ | ||
| 134 | + readonly fixture: string; | ||
| 135 | + readonly mode: TransportMode; | ||
| 136 | + readonly resolution: PrimServiceResolution; | ||
| 137 | + readonly expected: PrimNormalizedDepartureData; | ||
| 138 | +}> = [ | ||
| 139 | + { | ||
| 140 | + fixture: "bus", | ||
| 141 | + mode: "BUS", | ||
| 142 | + resolution: RESOLUTIONS.BUS, | ||
| 143 | + expected: expectedNormalized("REALTIME", [ | ||
| 144 | + expectedDeparture("2026-01-15T08:32:00Z", 2, "ON_TIME", { | ||
| 145 | + aimedIso: "2026-01-15T08:31:00Z", | ||
| 146 | + interval: 6, | ||
| 147 | + }), | ||
| 148 | + expectedDeparture("2026-01-15T08:38:00Z", 8, "ON_TIME", { | ||
| 149 | + aimedIso: "2026-01-15T08:37:30Z", | ||
| 150 | + }), | ||
| 151 | + ]), | ||
| 152 | + }, | ||
| 153 | + { | ||
| 154 | + fixture: "metro", | ||
| 155 | + mode: "METRO", | ||
| 156 | + resolution: RESOLUTIONS.METRO, | ||
| 157 | + expected: expectedNormalized("REALTIME", [ | ||
| 158 | + expectedDeparture("2026-01-15T08:34:00Z", 4, "ON_TIME", { | ||
| 159 | + aimedIso: "2026-01-15T08:33:00Z", | ||
| 160 | + }), | ||
| 161 | + ]), | ||
| 162 | + }, | ||
| 163 | + { | ||
| 164 | + fixture: "tram", | ||
| 165 | + mode: "TRAM", | ||
| 166 | + resolution: RESOLUTIONS.TRAM, | ||
| 167 | + expected: expectedNormalized("REALTIME", [ | ||
| 168 | + expectedDeparture("2026-01-15T08:36:00Z", 6, "ON_TIME", { | ||
| 169 | + aimedIso: "2026-01-15T08:35:00Z", | ||
| 170 | + }), | ||
| 171 | + ]), | ||
| 172 | + }, | ||
| 173 | + { | ||
| 174 | + fixture: "rer", | ||
| 175 | + mode: "RER", | ||
| 176 | + resolution: RESOLUTIONS.RER, | ||
| 177 | + expected: expectedNormalized("REALTIME", [ | ||
| 178 | + expectedDeparture("2026-01-15T08:33:00Z", 3, "ON_TIME", { | ||
| 179 | + aimedIso: "2026-01-15T08:32:30Z", | ||
| 180 | + interval: 14, | ||
| 181 | + }), | ||
| 182 | + expectedDeparture("2026-01-15T08:47:00Z", 17, "ON_TIME", { | ||
| 183 | + aimedIso: "2026-01-15T08:45:00Z", | ||
| 184 | + }), | ||
| 185 | + ]), | ||
| 186 | + }, | ||
| 187 | + { | ||
| 188 | + fixture: "transilien", | ||
| 189 | + mode: "TRANSILIEN", | ||
| 190 | + resolution: RESOLUTIONS.TRANSILIEN, | ||
| 191 | + expected: expectedNormalized("REALTIME", [ | ||
| 192 | + expectedDeparture("2026-01-15T08:41:00Z", 11, "ON_TIME", { | ||
| 193 | + aimedIso: "2026-01-15T08:40:00Z", | ||
| 194 | + interval: 14, | ||
| 195 | + }), | ||
| 196 | + expectedDeparture("2026-01-15T08:55:00Z", 25, "ON_TIME", { | ||
| 197 | + aimedIso: "2026-01-15T08:54:00Z", | ||
| 198 | + }), | ||
| 199 | + ]), | ||
| 200 | + }, | ||
| 201 | +]; | ||
| 202 | + | ||
| 203 | +interface RecordedMetric { | ||
| 204 | + readonly name: keyof typeof DEPARTURE_METRIC_NAME; | ||
| 205 | + readonly value: number; | ||
| 206 | +} | ||
| 207 | + | ||
| 208 | +function metricsRecorder(): DepartureMetrics & { | ||
| 209 | + readonly entries: RecordedMetric[]; | ||
| 210 | + count(name: keyof typeof DEPARTURE_METRIC_NAME): number; | ||
| 211 | +} { | ||
| 212 | + const entries: RecordedMetric[] = []; | ||
| 213 | + return { | ||
| 214 | + entries, | ||
| 215 | + record(name, value) { | ||
| 216 | + entries.push({ name, value }); | ||
| 217 | + }, | ||
| 218 | + count(name) { | ||
| 219 | + return entries.filter((entry) => entry.name === name).length; | ||
| 220 | + }, | ||
| 221 | + }; | ||
| 222 | +} | ||
| 223 | + | ||
| 224 | +function flush(): Promise<void> { | ||
| 225 | + const { promise, resolve } = Promise.withResolvers<void>(); | ||
| 226 | + setImmediate(resolve); | ||
| 227 | + return promise; | ||
| 228 | +} | ||
| 229 | + | ||
| 230 | +function snapshot(fetchedAt: number, sourceUpdatedAt = fetchedAt - 10): PublicDepartureData { | ||
| 231 | + return { | ||
| 232 | + schemaVersion: SCHEMA_VERSION, | ||
| 233 | + fetchedAt, | ||
| 234 | + sourceUpdatedAt, | ||
| 235 | + freshness: "REALTIME", | ||
| 236 | + departures: [ | ||
| 237 | + { expectedAt: fetchedAt + 120, aimedAt: fetchedAt + 60, minutes: 2, status: "ON_TIME" }, | ||
| 238 | + { expectedAt: fetchedAt + 360, minutes: 6, status: "DELAYED" }, | ||
| 239 | + ], | ||
| 240 | + }; | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +function errorSnapshot( | ||
| 244 | + code: ErrorCode, | ||
| 245 | + occurredAt: number, | ||
| 246 | + retryAfterSeconds?: number, | ||
| 247 | +): PublicDepartureError { | ||
| 248 | + return { | ||
| 249 | + schemaVersion: SCHEMA_VERSION, | ||
| 250 | + code, | ||
| 251 | + occurredAt, | ||
| 252 | + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), | ||
| 253 | + }; | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +interface HandlerCallOptions { | ||
| 257 | + readonly serviceId: string; | ||
| 258 | + readonly requestId?: string; | ||
| 259 | + readonly favoriteId?: string; | ||
| 260 | + readonly apiKey?: string; | ||
| 261 | + readonly state: DepartureRuntimeState; | ||
| 262 | + readonly resolve: (request: DepartureResolverRequest) => Promise<DepartureResolverResult>; | ||
| 263 | + readonly nowMilliseconds?: () => number; | ||
| 264 | + readonly metrics?: DepartureMetrics; | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +function requestDepartures(options: HandlerCallOptions): Promise<DepartureHandlerResponse> { | ||
| 268 | + return handleDepartureRequest( | ||
| 269 | + { | ||
| 270 | + authorization: `Bearer ${options.apiKey ?? KEY_A}`, | ||
| 271 | + body: { | ||
| 272 | + schemaVersion: SCHEMA_VERSION, | ||
| 273 | + requestId: options.requestId ?? "dep-request", | ||
| 274 | + favoriteId: options.favoriteId ?? "dep-favorite", | ||
| 275 | + serviceId: options.serviceId, | ||
| 276 | + }, | ||
| 277 | + }, | ||
| 278 | + { | ||
| 279 | + resolve: options.resolve, | ||
| 280 | + state: options.state, | ||
| 281 | + ...(options.nowMilliseconds === undefined ? {} : { nowMilliseconds: options.nowMilliseconds }), | ||
| 282 | + ...(options.metrics === undefined ? {} : { metrics: options.metrics }), | ||
| 283 | + }, | ||
| 284 | + ); | ||
| 285 | +} | ||
| 286 | + | ||
| 287 | +function expectDeparture(result: DepartureHandlerResponse): DepartureResult { | ||
| 288 | + assert.equal(isDepartureResult(result), true); | ||
| 289 | + if (!isDepartureResult(result)) assert.fail("expected a departure result"); | ||
| 290 | + return result; | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +function expectError(result: DepartureHandlerResponse): ErrorResult { | ||
| 294 | + assert.equal(isErrorResult(result), true); | ||
| 295 | + if (!isErrorResult(result)) assert.fail("expected an error result"); | ||
| 296 | + return result; | ||
| 297 | +} | ||
| 298 | + | ||
| 299 | +function expectResolverError(result: DepartureResolverResult): PublicDepartureError { | ||
| 300 | + if ("code" in result) return result; | ||
| 301 | + assert.fail("expected a public departure error"); | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +function recordingResolver( | ||
| 305 | + produce: (request: DepartureResolverRequest, call: number) => Promise<DepartureResolverResult>, | ||
| 306 | +): { readonly seen: DepartureResolverRequest[]; readonly resolve: HandlerCallOptions["resolve"] } { | ||
| 307 | + const seen: DepartureResolverRequest[] = []; | ||
| 308 | + return { | ||
| 309 | + seen, | ||
| 310 | + resolve: async (request) => { | ||
| 311 | + seen.push({ ...request }); | ||
| 312 | + return produce(request, seen.length); | ||
| 313 | + }, | ||
| 314 | + }; | ||
| 315 | +} | ||
| 316 | + | ||
| 317 | +test("normalize maps the five deterministic mode fixtures to frozen REALTIME results", async () => { | ||
| 318 | + for (const entry of MODE_FIXTURES) { | ||
| 319 | + const result = normalizePrimDepartureResponse( | ||
| 320 | + await loadFixture(entry.fixture), | ||
| 321 | + entry.resolution, | ||
| 322 | + FETCHED_AT, | ||
| 323 | + ); | ||
| 324 | + assert.deepEqual(result, entry.expected, `fixture ${entry.fixture} (${entry.mode})`); | ||
| 325 | + assert.equal(Object.isFrozen(result), true); | ||
| 326 | + assert.equal(Object.isFrozen(result.departures), true); | ||
| 327 | + for (const departure of result.departures) assert.equal(Object.isFrozen(departure), true); | ||
| 328 | + } | ||
| 329 | +}); | ||
| 330 | + | ||
| 331 | +test("delayed fixture keeps expected and aimed distinct and computes minutes from expected", async () => { | ||
| 332 | + const result = normalizePrimDepartureResponse( | ||
| 333 | + await loadFixture("delayed"), | ||
| 334 | + RESOLUTIONS.METRO, | ||
| 335 | + FETCHED_AT, | ||
| 336 | + ); | ||
| 337 | + assert.deepEqual(result, expectedNormalized("REALTIME", [ | ||
| 338 | + expectedDeparture("2026-01-15T08:37:00Z", 7, "DELAYED", { | ||
| 339 | + aimedIso: "2026-01-15T08:33:00Z", | ||
| 340 | + }), | ||
| 341 | + ])); | ||
| 342 | +}); | ||
| 343 | + | ||
| 344 | +test("cancelled fixture emits CANCELLED without intervals and skips it as an interval anchor", async () => { | ||
| 345 | + const result = normalizePrimDepartureResponse( | ||
| 346 | + await loadFixture("cancelled"), | ||
| 347 | + RESOLUTIONS.TRAM, | ||
| 348 | + FETCHED_AT, | ||
| 349 | + ); | ||
| 350 | + assert.deepEqual(result, expectedNormalized("REALTIME", [ | ||
| 351 | + expectedDeparture("2026-01-15T08:32:00Z", 2, "CANCELLED", { | ||
| 352 | + aimedIso: "2026-01-15T08:31:30Z", | ||
| 353 | + }), | ||
| 354 | + expectedDeparture("2026-01-15T08:35:00Z", 5, "ON_TIME", { | ||
| 355 | + aimedIso: "2026-01-15T08:34:30Z", | ||
| 356 | + interval: 5, | ||
| 357 | + }), | ||
| 358 | + expectedDeparture("2026-01-15T08:40:00Z", 10, "ON_TIME", { | ||
| 359 | + aimedIso: "2026-01-15T08:39:30Z", | ||
| 360 | + }), | ||
| 361 | + ])); | ||
| 362 | + assert.equal("nextIntervalMinutes" in result.departures[0]!, false); | ||
| 363 | +}); | ||
| 364 | + | ||
| 365 | +test("scheduled-only fixture falls back to aimed times with SCHEDULED freshness and UNKNOWN status", async () => { | ||
| 366 | + const result = normalizePrimDepartureResponse( | ||
| 367 | + await loadFixture("scheduled-only"), | ||
| 368 | + RESOLUTIONS.TRANSILIEN, | ||
| 369 | + FETCHED_AT, | ||
| 370 | + ); | ||
| 371 | + assert.deepEqual(result, expectedNormalized("SCHEDULED", [ | ||
| 372 | + expectedDeparture("2026-01-15T08:44:00Z", 14, "UNKNOWN", { | ||
| 373 | + aimedIso: "2026-01-15T08:44:00Z", | ||
| 374 | + interval: 14, | ||
| 375 | + }), | ||
| 376 | + expectedDeparture("2026-01-15T08:58:00Z", 28, "UNKNOWN", { | ||
| 377 | + aimedIso: "2026-01-15T08:58:00Z", | ||
| 378 | + }), | ||
| 379 | + ])); | ||
| 380 | +}); | ||
| 381 | + | ||
| 382 | +test("mixed fixture labels the result MIXED exactly when expected and aimed-only visits coexist", async () => { | ||
| 383 | + const result = normalizePrimDepartureResponse( | ||
| 384 | + await loadFixture("mixed"), | ||
| 385 | + RESOLUTIONS.RER, | ||
| 386 | + FETCHED_AT, | ||
| 387 | + ); | ||
| 388 | + assert.deepEqual(result, expectedNormalized("MIXED", [ | ||
| 389 | + expectedDeparture("2026-01-15T08:33:00Z", 3, "ON_TIME", { | ||
| 390 | + aimedIso: "2026-01-15T08:32:30Z", | ||
| 391 | + interval: 8, | ||
| 392 | + }), | ||
| 393 | + expectedDeparture("2026-01-15T08:41:00Z", 11, "UNKNOWN", { | ||
| 394 | + aimedIso: "2026-01-15T08:41:00Z", | ||
| 395 | + interval: 8, | ||
| 396 | + }), | ||
| 397 | + expectedDeparture("2026-01-15T08:49:00Z", 19, "ON_TIME", { | ||
| 398 | + aimedIso: "2026-01-15T08:48:00Z", | ||
| 399 | + }), | ||
| 400 | + ])); | ||
| 401 | +}); | ||
| 402 | + | ||
| 403 | +test("partial fixture filters foreign stops and lines and derives sourceUpdatedAt from matched visits only", async () => { | ||
| 404 | + const result = normalizePrimDepartureResponse( | ||
| 405 | + await loadFixture("partial"), | ||
| 406 | + RESOLUTIONS.BUS, | ||
| 407 | + FETCHED_AT, | ||
| 408 | + ); | ||
| 409 | + assert.deepEqual(result, expectedNormalized( | ||
| 410 | + "REALTIME", | ||
| 411 | + [ | ||
| 412 | + expectedDeparture("2026-01-15T08:34:00Z", 4, "ON_TIME", { | ||
| 413 | + aimedIso: "2026-01-15T08:33:00Z", | ||
| 414 | + interval: 8, | ||
| 415 | + }), | ||
| 416 | + expectedDeparture("2026-01-15T08:42:00Z", 12, "ON_TIME"), | ||
| 417 | + ], | ||
| 418 | + sec("2026-01-15T08:29:35Z"), | ||
| 419 | + )); | ||
| 420 | + assert.equal("aimedAt" in result.departures[1]!, false); | ||
| 421 | +}); | ||
| 422 | + | ||
| 423 | +test("malformed fixture without call times is rejected with a stable normalization error", async () => { | ||
| 424 | + const payload = await loadFixture("malformed"); | ||
| 425 | + assert.throws( | ||
| 426 | + () => normalizePrimDepartureResponse(payload, RESOLUTIONS.METRO, FETCHED_AT), | ||
| 427 | + (error: unknown) => | ||
| 428 | + error instanceof PrimNormalizationError | ||
| 429 | + && /ExpectedDepartureTime or AimedDepartureTime/u.test(error.message), | ||
| 430 | + ); | ||
| 431 | +}); | ||
| 432 | + | ||
| 433 | +test("structurally malformed responses reject with PrimNormalizationError instead of guessing", () => { | ||
| 434 | + const validVisit = { | ||
| 435 | + RecordedAtTime: "2026-01-15T08:29:45Z", | ||
| 436 | + MonitoringRef: { value: RESOLUTIONS.BUS.monitoringRef }, | ||
| 437 | + MonitoredVehicleJourney: { | ||
| 438 | + LineRef: { value: RESOLUTIONS.BUS.lineRef }, | ||
| 439 | + MonitoredCall: { ExpectedDepartureTime: "2026-01-15T08:32:00Z" }, | ||
| 440 | + }, | ||
| 441 | + }; | ||
| 442 | + const envelope = (delivery: unknown) => ({ | ||
| 443 | + Siri: { ServiceDelivery: { StopMonitoringDelivery: [delivery] } }, | ||
| 444 | + }); | ||
| 445 | + const cases: ReadonlyArray<[unknown, RegExp]> = [ | ||
| 446 | + [null, /response must be a JSON object/u], | ||
| 447 | + [{}, /Siri must be an object/u], | ||
| 448 | + [{ Siri: {} }, /Siri\.ServiceDelivery must be an object/u], | ||
| 449 | + [envelope({ MonitoredStopVisit: {} }), /MonitoredStopVisit must be an array/u], | ||
| 450 | + [envelope({ MonitoredStopVisit: [{ ...validVisit, RecordedAtTime: 17 }] }), /RecordedAtTime/u], | ||
| 451 | + [envelope({ MonitoredStopVisit: [{ ...validVisit, MonitoringRef: { value: "" } }] }), /MonitoringRef/u], | ||
| 452 | + [ | ||
| 453 | + envelope({ MonitoredStopVisit: [{ ...validVisit, MonitoringRef: { value: "IDFM:SP:9999" } }] }), | ||
| 454 | + /no MonitoredStopVisit matches/u, | ||
| 455 | + ], | ||
| 456 | + ]; | ||
| 457 | + for (const [payload, pattern] of cases) { | ||
| 458 | + assert.throws( | ||
| 459 | + () => normalizePrimDepartureResponse(payload, RESOLUTIONS.BUS, FETCHED_AT), | ||
| 460 | + (error: unknown) => error instanceof PrimNormalizationError && pattern.test(error.message), | ||
| 461 | + ); | ||
| 462 | + } | ||
| 463 | +}); | ||
| 464 | + | ||
| 465 | +test("normalization accepts RFC3339 timestamps with an explicit numeric offset", () => { | ||
| 466 | + const result = normalizePrimDepartureResponse( | ||
| 467 | + { | ||
| 468 | + Siri: { | ||
| 469 | + ServiceDelivery: { | ||
| 470 | + ResponseTimestamp: "2026-01-15T09:30:00.250+01:00", | ||
| 471 | + StopMonitoringDelivery: [{ | ||
| 472 | + ResponseTimestamp: "2026-01-15T09:29:58+01:00", | ||
| 473 | + MonitoredStopVisit: [{ | ||
| 474 | + RecordedAtTime: "2026-01-15T09:29:45+01:00", | ||
| 475 | + MonitoringRef: { value: RESOLUTIONS.BUS.monitoringRef }, | ||
| 476 | + MonitoredVehicleJourney: { | ||
| 477 | + LineRef: { value: RESOLUTIONS.BUS.lineRef }, | ||
| 478 | + MonitoredCall: { | ||
| 479 | + ExpectedDepartureTime: "2026-01-15T09:32:00.999+01:00", | ||
| 480 | + AimedDepartureTime: "2026-01-15T09:31:00+01:00", | ||
| 481 | + DepartureStatus: "ontime", | ||
| 482 | + }, | ||
| 483 | + }, | ||
| 484 | + }], | ||
| 485 | + }], | ||
| 486 | + }, | ||
| 487 | + }, | ||
| 488 | + }, | ||
| 489 | + RESOLUTIONS.BUS, | ||
| 490 | + FETCHED_AT, | ||
| 491 | + ); | ||
| 492 | + | ||
| 493 | + assert.deepEqual(result, expectedNormalized("REALTIME", [ | ||
| 494 | + expectedDeparture("2026-01-15T08:32:00Z", 2, "ON_TIME", { | ||
| 495 | + aimedIso: "2026-01-15T08:31:00Z", | ||
| 496 | + }), | ||
| 497 | + ])); | ||
| 498 | +}); | ||
| 499 | + | ||
| 500 | +test("normalization rejects timestamps without an explicit offset or with invalid components", () => { | ||
| 501 | + const payload = (timestamp: string) => ({ | ||
| 502 | + Siri: { | ||
| 503 | + ServiceDelivery: { | ||
| 504 | + StopMonitoringDelivery: [{ | ||
| 505 | + MonitoredStopVisit: [{ | ||
| 506 | + RecordedAtTime: "2026-01-15T08:29:45Z", | ||
| 507 | + MonitoringRef: { value: RESOLUTIONS.BUS.monitoringRef }, | ||
| 508 | + MonitoredVehicleJourney: { | ||
| 509 | + LineRef: { value: RESOLUTIONS.BUS.lineRef }, | ||
| 510 | + MonitoredCall: { ExpectedDepartureTime: timestamp }, | ||
| 511 | + }, | ||
| 512 | + }], | ||
| 513 | + }], | ||
| 514 | + }, | ||
| 515 | + }, | ||
| 516 | + }); | ||
| 517 | + const invalid = [ | ||
| 518 | + "2026-01-15T08:32:00", | ||
| 519 | + "2026-02-29T08:32:00Z", | ||
| 520 | + "2026-01-15T24:00:00Z", | ||
| 521 | + "2026-01-15T08:60:00Z", | ||
| 522 | + "2026-01-15T08:32:60Z", | ||
| 523 | + "2026-01-15T08:32:00+24:00", | ||
| 524 | + "2026-01-15T08:32:00+01:60", | ||
| 525 | + "2026-01-15T08:32:00z", | ||
| 526 | + "2026-01-15T08:32:00Z\n", | ||
| 527 | + "1969-12-31T23:59:59Z", | ||
| 528 | + "2106-02-07T06:28:16Z", | ||
| 529 | + ] as const; | ||
| 530 | + | ||
| 531 | + for (const timestamp of invalid) { | ||
| 532 | + assert.throws( | ||
| 533 | + () => normalizePrimDepartureResponse(payload(timestamp), RESOLUTIONS.BUS, FETCHED_AT), | ||
| 534 | + (error: unknown) => | ||
| 535 | + error instanceof PrimNormalizationError | ||
| 536 | + && /ExpectedDepartureTime is not a valid contract timestamp/u.test(error.message), | ||
| 537 | + timestamp, | ||
| 538 | + ); | ||
| 539 | + } | ||
| 540 | +}); | ||
| 541 | + | ||
| 542 | +test("normalization sorts by expected time and bounds results to the shared departure limit", () => { | ||
| 543 | + const visit = (expectedIso: string) => ({ | ||
| 544 | + RecordedAtTime: "2026-01-15T08:29:50Z", | ||
| 545 | + MonitoringRef: { value: RESOLUTIONS.BUS.monitoringRef }, | ||
| 546 | + MonitoredVehicleJourney: { | ||
| 547 | + LineRef: { value: RESOLUTIONS.BUS.lineRef }, | ||
| 548 | + MonitoredCall: { ExpectedDepartureTime: expectedIso, DepartureStatus: "ontime" }, | ||
| 549 | + }, | ||
| 550 | + }); | ||
| 551 | + const result = normalizePrimDepartureResponse( | ||
| 552 | + { | ||
| 553 | + Siri: { | ||
| 554 | + ServiceDelivery: { | ||
| 555 | + StopMonitoringDelivery: [{ | ||
| 556 | + MonitoredStopVisit: ["08:40", "08:35", "08:38", "08:37", "08:36", "08:39"] | ||
| 557 | + .map((time) => visit(`2026-01-15T${time}:00Z`)), | ||
| 558 | + }], | ||
| 559 | + }, | ||
| 560 | + }, | ||
| 561 | + }, | ||
| 562 | + RESOLUTIONS.BUS, | ||
| 563 | + FETCHED_AT, | ||
| 564 | + ); | ||
| 565 | + assert.equal(result.departures.length, LIMITS.departures); | ||
| 566 | + assert.deepEqual(result.departures.map((departure) => departure.minutes), [5, 6, 7, 8]); | ||
| 567 | + assert.deepEqual( | ||
| 568 | + result.departures.slice(0, -1).map((departure) => departure.nextIntervalMinutes), | ||
| 569 | + [1, 1, 1], | ||
| 570 | + ); | ||
| 571 | +}); | ||
| 572 | + | ||
| 573 | +test("buildPrimStopMonitoringUrl targets the exact PRIM path with ordered MonitoringRef and LineRef", () => { | ||
| 574 | + const url = buildPrimStopMonitoringUrl(RESOLUTIONS.BUS); | ||
| 575 | + assert.equal(url.origin, PRIM_ORIGIN); | ||
| 576 | + assert.equal(url.pathname, "/marketplace/stop-monitoring"); | ||
| 577 | + assert.deepEqual([...url.searchParams.keys()], ["MonitoringRef", "LineRef"]); | ||
| 578 | + assert.equal(url.searchParams.get("MonitoringRef"), RESOLUTIONS.BUS.monitoringRef); | ||
| 579 | + assert.equal(url.searchParams.get("LineRef"), RESOLUTIONS.BUS.lineRef); | ||
| 580 | + const encoded = (value: string) => encodeURIComponent(value); | ||
| 581 | + assert.equal( | ||
| 582 | + url.href, | ||
| 583 | + `${PRIM_ORIGIN}/marketplace/stop-monitoring` | ||
| 584 | + + `?MonitoringRef=${encoded(RESOLUTIONS.BUS.monitoringRef)}` | ||
| 585 | + + `&LineRef=${encoded(RESOLUTIONS.BUS.lineRef)}`, | ||
| 586 | + ); | ||
| 587 | +}); | ||
| 588 | + | ||
| 589 | +test("parseRetryAfterSeconds accepts only bounded non-negative integer delta-seconds", () => { | ||
| 590 | + const cases: ReadonlyArray<[unknown, number | undefined]> = [ | ||
| 591 | + ["30", 30], | ||
| 592 | + ["0", 0], | ||
| 593 | + ["86400", 86_400], | ||
| 594 | + ["007", 7], | ||
| 595 | + ["86401", undefined], | ||
| 596 | + ["-5", undefined], | ||
| 597 | + ["1.5", undefined], | ||
| 598 | + ["soon", undefined], | ||
| 599 | + ["", undefined], | ||
| 600 | + [90, 90], | ||
| 601 | + [0, 0], | ||
| 602 | + [86_400, 86_400], | ||
| 603 | + [86_401, undefined], | ||
| 604 | + [-1, undefined], | ||
| 605 | + [1.5, undefined], | ||
| 606 | + [Number.NaN, undefined], | ||
| 607 | + [null, undefined], | ||
| 608 | + [undefined, undefined], | ||
| 609 | + ]; | ||
| 610 | + for (const [value, expected] of cases) { | ||
| 611 | + assert.equal(parseRetryAfterSeconds(value), expected, `value ${String(value)}`); | ||
| 612 | + } | ||
| 613 | +}); | ||
| 614 | + | ||
| 615 | +function catalogReader(table: Record<string, CatalogServiceResolution>): CatalogReader { | ||
| 616 | + return { | ||
| 617 | + searchPlaces: () => [], | ||
| 618 | + listServices: () => undefined, | ||
| 619 | + resolveService: (serviceId) => | ||
| 620 | + table[serviceId] ?? { status: "UNRESOLVED", code: "INVALID_SERVICE" }, | ||
| 621 | + }; | ||
| 622 | +} | ||
| 623 | + | ||
| 624 | +function relayJson(payload: unknown, status = 200, retryAfter?: string): PrimDepartureRelayResponse { | ||
| 625 | + return { | ||
| 626 | + status, | ||
| 627 | + bytes: encoder.encode(JSON.stringify(payload)), | ||
| 628 | + ...(retryAfter === undefined ? {} : { retryAfter }), | ||
| 629 | + }; | ||
| 630 | +} | ||
| 631 | + | ||
| 632 | +const BUS_SERVICE = "svc-bus"; | ||
| 633 | + | ||
| 634 | +function busResolver( | ||
| 635 | + relay: PrimDepartureRelay, | ||
| 636 | + options: { | ||
| 637 | + readonly limiter?: UpstreamLimiter; | ||
| 638 | + readonly metrics?: DepartureMetrics; | ||
| 639 | + readonly clock?: () => number; | ||
| 640 | + } = {}, | ||
| 641 | +) { | ||
| 642 | + return createPrimDepartureResolver({ | ||
| 643 | + catalog: catalogReader({ | ||
| 644 | + [BUS_SERVICE]: { status: "RESOLVED", ...RESOLUTIONS.BUS }, | ||
| 645 | + }), | ||
| 646 | + relay, | ||
| 647 | + clock: options.clock ?? (() => CLOCK_MS), | ||
| 648 | + ...(options.metrics === undefined ? {} : { metrics: options.metrics }), | ||
| 649 | + ...(options.limiter === undefined ? {} : { limiter: options.limiter }), | ||
| 650 | + }); | ||
| 651 | +} | ||
| 652 | + | ||
| 653 | +test("resolver relays the exact PRIM URL and headers and normalizes the bus fixture", async () => { | ||
| 654 | + const busFixture = await loadFixture("bus"); | ||
| 655 | + const captures: Array<{ target: URL; init: PrimFetchInit }> = []; | ||
| 656 | + const fetchFake: PrimFetch = async (target, init) => { | ||
| 657 | + captures.push({ target, init }); | ||
| 658 | + return { | ||
| 659 | + status: 200, | ||
| 660 | + headers: { get: () => null }, | ||
| 661 | + body: new ReadableStream<Uint8Array>({ | ||
| 662 | + start(controller) { | ||
| 663 | + controller.enqueue(encoder.encode(JSON.stringify(busFixture))); | ||
| 664 | + controller.close(); | ||
| 665 | + }, | ||
| 666 | + }), | ||
| 667 | + }; | ||
| 668 | + }; | ||
| 669 | + const resolver = busResolver(({ target, apiKey }) => | ||
| 670 | + relayPrimRequest({ target, authorization: `Bearer ${apiKey}` }, { fetch: fetchFake })); | ||
| 671 | + const result = await resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }); | ||
| 672 | + | ||
| 673 | + assert.deepEqual(result, normalizePrimDepartureResponse(busFixture, RESOLUTIONS.BUS, CLOCK_SEC)); | ||
| 674 | + assert.equal(captures.length, 1); | ||
| 675 | + const { target, init } = captures[0]!; | ||
| 676 | + assert.equal(target.href, buildPrimStopMonitoringUrl(RESOLUTIONS.BUS).href); | ||
| 677 | + assert.equal(init.method, "GET"); | ||
| 678 | + assert.deepEqual(init.headers, { | ||
| 679 | + apikey: KEY_A, | ||
| 680 | + accept: "application/json", | ||
| 681 | + "accept-encoding": "gzip", | ||
| 682 | + }); | ||
| 683 | + assert.equal(init.redirect, "manual"); | ||
| 684 | + assert.equal(init.credentials, "omit"); | ||
| 685 | + assert.equal(init.cache, "no-store"); | ||
| 686 | +}); | ||
| 687 | + | ||
| 688 | +test("resolver rejects unknown catalog services without relay or upstream metrics", async () => { | ||
| 689 | + const metrics = metricsRecorder(); | ||
| 690 | + let relayCalls = 0; | ||
| 691 | + const resolver = busResolver(async () => { | ||
| 692 | + relayCalls += 1; | ||
| 693 | + return relayJson({}); | ||
| 694 | + }, { metrics }); | ||
| 695 | + const result = await resolver({ serviceId: "unknown-service", apiKey: KEY_A }); | ||
| 696 | + assert.deepEqual(result, errorSnapshot("INVALID_SERVICE", CLOCK_SEC)); | ||
| 697 | + assert.equal(relayCalls, 0); | ||
| 698 | + assert.deepEqual(metrics.entries, []); | ||
| 699 | +}); | ||
| 700 | + | ||
| 701 | +test("resolver records actual upstream 401 and 403 statuses before mapping them", async () => { | ||
| 702 | + for (const status of [401, 403]) { | ||
| 703 | + const metrics = metricsRecorder(); | ||
| 704 | + const resolver = busResolver( | ||
| 705 | + async () => relayJson({ message: "nope" }, status), | ||
| 706 | + { metrics }, | ||
| 707 | + ); | ||
| 708 | + const result = expectResolverError( | ||
| 709 | + await resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | ||
| 710 | + ); | ||
| 711 | + assert.deepEqual(result, errorSnapshot("API_KEY_INVALID", CLOCK_SEC)); | ||
| 712 | + assert.deepEqual(metrics.entries, [ | ||
| 713 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, value: 1 }, | ||
| 714 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, value: status }, | ||
| 715 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, value: 0 }, | ||
| 716 | + ]); | ||
| 717 | + } | ||
| 718 | +}); | ||
| 719 | + | ||
| 720 | +test("resolver ignores metric recorder failures", async () => { | ||
| 721 | + const busFixture = await loadFixture("bus"); | ||
| 722 | + let metricCalls = 0; | ||
| 723 | + const metrics: DepartureMetrics = { | ||
| 724 | + record() { | ||
| 725 | + metricCalls += 1; | ||
| 726 | + throw new Error("metric recorder unavailable"); | ||
| 727 | + }, | ||
| 728 | + }; | ||
| 729 | + const resolver = busResolver( | ||
| 730 | + async () => relayJson(busFixture), | ||
| 731 | + { metrics }, | ||
| 732 | + ); | ||
| 733 | + | ||
| 734 | + const result = await resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }); | ||
| 735 | + assert.deepEqual(result, normalizePrimDepartureResponse(busFixture, RESOLUTIONS.BUS, CLOCK_SEC)); | ||
| 736 | + assert.equal(metricCalls, 3); | ||
| 737 | +}); | ||
| 738 | + | ||
| 739 | +test("resolver maps 429 to RATE_LIMITED carrying only validated Retry-After values", async () => { | ||
| 740 | + const limited = expectResolverError( | ||
| 741 | + await busResolver(async () => relayJson({}, 429, "30"))({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | ||
| 742 | + ); | ||
| 743 | + assert.deepEqual(limited, errorSnapshot("RATE_LIMITED", CLOCK_SEC, 30)); | ||
| 744 | + | ||
| 745 | + const unparsable = expectResolverError( | ||
| 746 | + await busResolver(async () => relayJson({}, 429, "later"))({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | ||
| 747 | + ); | ||
| 748 | + assert.deepEqual(Object.keys(unparsable).sort(), ["code", "occurredAt", "schemaVersion"]); | ||
| 749 | + | ||
| 750 | + const serverError = expectResolverError( | ||
| 751 | + await busResolver(async () => relayJson({}, 503))({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | ||
| 752 | + ); | ||
| 753 | + assert.deepEqual(serverError, errorSnapshot("SOURCE_UNAVAILABLE", CLOCK_SEC)); | ||
| 754 | +}); | ||
| 755 | + | ||
| 756 | +test("resolver maps malformed, non-UTF-8, thrown, and invalid relay results to stable errors", async () => { | ||
| 757 | + const malformed = expectResolverError( | ||
| 758 | + await busResolver(async () => relayJson(await loadFixture("malformed")))({ | ||
| 759 | + serviceId: BUS_SERVICE, | ||
| 760 | + apiKey: KEY_A, | ||
| 761 | + }), | ||
| 762 | + ); | ||
| 763 | + assert.deepEqual(malformed, errorSnapshot("INVALID_RESPONSE", CLOCK_SEC)); | ||
| 764 | + | ||
| 765 | + const invalidUtf8 = expectResolverError( | ||
| 766 | + await busResolver(async () => ({ status: 200, bytes: new Uint8Array([0xff, 0xfe]) }))({ | ||
| 767 | + serviceId: BUS_SERVICE, | ||
| 768 | + apiKey: KEY_A, | ||
| 769 | + }), | ||
| 770 | + ); | ||
| 771 | + assert.deepEqual(invalidUtf8, errorSnapshot("INVALID_RESPONSE", CLOCK_SEC)); | ||
| 772 | + | ||
| 773 | + const thrownMetrics = metricsRecorder(); | ||
| 774 | + const thrown = expectResolverError( | ||
| 775 | + await busResolver(async () => { | ||
| 776 | + throw new Error(`relay exploded for ${KEY_A}`); | ||
| 777 | + }, { metrics: thrownMetrics })({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | ||
| 778 | + ); | ||
| 779 | + assert.deepEqual(thrown, errorSnapshot("SOURCE_UNAVAILABLE", CLOCK_SEC)); | ||
| 780 | + assert.deepEqual(thrownMetrics.entries, [ | ||
| 781 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, value: 1 }, | ||
| 782 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, value: 0 }, | ||
| 783 | + { name: DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, value: 0 }, | ||
| 784 | + ]); | ||
| 785 | + | ||
| 786 | + const invalidEnvelope = expectResolverError( | ||
| 787 | + await busResolver( | ||
| 788 | + async () => ({ status: "200" }) as unknown as PrimDepartureRelayResponse, | ||
| 789 | + )({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | ||
| 790 | + ); | ||
| 791 | + assert.deepEqual(invalidEnvelope, errorSnapshot("INVALID_RESPONSE", CLOCK_SEC)); | ||
| 792 | +}); | ||
| 793 | + | ||
| 794 | +test("upstream limiter enforces its configured global maximum and releases queued work", async () => { | ||
| 795 | + assert.throws(() => createUpstreamLimiter(0), RangeError); | ||
| 796 | + assert.throws(() => createUpstreamLimiter(1.5), RangeError); | ||
| 797 | + | ||
| 798 | + const limiter = createUpstreamLimiter(3); | ||
| 799 | + let started = 0; | ||
| 800 | + let concurrent = 0; | ||
| 801 | + let peak = 0; | ||
| 802 | + const gates = Array.from({ length: 5 }, () => Promise.withResolvers<void>()); | ||
| 803 | + const runs = gates.map((gate) => | ||
| 804 | + limiter.run(async () => { | ||
| 805 | + started += 1; | ||
| 806 | + concurrent += 1; | ||
| 807 | + peak = Math.max(peak, concurrent); | ||
| 808 | + await gate.promise; | ||
| 809 | + concurrent -= 1; | ||
| 810 | + return started; | ||
| 811 | + })); | ||
| 812 | + | ||
| 813 | + await flush(); | ||
| 814 | + assert.equal(started, 3); | ||
| 815 | + assert.equal(peak, 3); | ||
| 816 | + | ||
| 817 | + gates[0]!.resolve(); | ||
| 818 | + await runs[0]!; | ||
| 819 | + await flush(); | ||
| 820 | + assert.equal(started, 4); | ||
| 821 | + assert.equal(peak, 3); | ||
| 822 | + | ||
| 823 | + gates[1]!.resolve(); | ||
| 824 | + gates[2]!.resolve(); | ||
| 825 | + await Promise.all([runs[1]!, runs[2]!]); | ||
| 826 | + await flush(); | ||
| 827 | + assert.equal(started, 5); | ||
| 828 | + assert.equal(peak, 3); | ||
| 829 | + | ||
| 830 | + gates[3]!.resolve(); | ||
| 831 | + gates[4]!.resolve(); | ||
| 832 | + assert.equal((await Promise.all(runs)).length, 5); | ||
| 833 | + assert.equal(peak, 3); | ||
| 834 | +}); | ||
| 835 | + | ||
| 836 | +test("resolver metrics exclude limiter queue time and cover only active relay calls", async () => { | ||
| 837 | + const limiter = createUpstreamLimiter(1); | ||
| 838 | + const metrics = metricsRecorder(); | ||
| 839 | + const response = relayJson(await loadFixture("bus")); | ||
| 840 | + let now = CLOCK_MS; | ||
| 841 | + let relayCalls = 0; | ||
| 842 | + let relayPeak = 0; | ||
| 843 | + let relayActive = 0; | ||
| 844 | + const gates = [ | ||
| 845 | + Promise.withResolvers<PrimDepartureRelayResponse>(), | ||
| 846 | + Promise.withResolvers<PrimDepartureRelayResponse>(), | ||
| 847 | + ]; | ||
| 848 | + const resolver = busResolver(async () => { | ||
| 849 | + relayCalls += 1; | ||
| 850 | + relayActive += 1; | ||
| 851 | + relayPeak = Math.max(relayPeak, relayActive); | ||
| 852 | + try { | ||
| 853 | + return await gates[relayCalls - 1]!.promise; | ||
| 854 | + } finally { | ||
| 855 | + relayActive -= 1; | ||
| 856 | + } | ||
| 857 | + }, { limiter, metrics, clock: () => now }); | ||
| 858 | + | ||
| 859 | + const first = resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }); | ||
| 860 | + await flush(); | ||
| 861 | + assert.equal(relayCalls, 1); | ||
| 862 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 1); | ||
| 863 | + | ||
| 864 | + const second = resolver({ serviceId: BUS_SERVICE, apiKey: KEY_B }); | ||
| 865 | + await flush(); | ||
| 866 | + assert.equal(relayCalls, 1); | ||
| 867 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 1); | ||
| 868 | + | ||
| 869 | + now += 5_000; | ||
| 870 | + gates[0]!.resolve(response); | ||
| 871 | + const firstResult = await first; | ||
| 872 | + assert.equal( | ||
| 873 | + isDepartureResult({ ...firstResult, requestId: "probe", favoriteId: "probe" }), | ||
| 874 | + true, | ||
| 875 | + ); | ||
| 876 | + await flush(); | ||
| 877 | + assert.equal(relayCalls, 2); | ||
| 878 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 2); | ||
| 879 | + | ||
| 880 | + now += 25; | ||
| 881 | + gates[1]!.resolve(response); | ||
| 882 | + const secondResult = await second; | ||
| 883 | + assert.equal( | ||
| 884 | + isDepartureResult({ ...secondResult, requestId: "probe", favoriteId: "probe" }), | ||
| 885 | + true, | ||
| 886 | + ); | ||
| 887 | + assert.equal(relayPeak, 1); | ||
| 888 | + assert.deepEqual( | ||
| 889 | + metrics.entries | ||
| 890 | + .filter((entry) => entry.name === DEPARTURE_METRIC_NAME.UPSTREAM_STATUS) | ||
| 891 | + .map((entry) => entry.value), | ||
| 892 | + [200, 200], | ||
| 893 | + ); | ||
| 894 | + assert.deepEqual( | ||
| 895 | + metrics.entries | ||
| 896 | + .filter((entry) => entry.name === DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS) | ||
| 897 | + .map((entry) => entry.value), | ||
| 898 | + [5_000, 25], | ||
| 899 | + ); | ||
| 900 | +}); | ||
| 901 | + | ||
| 902 | +test("the default process limiter caps concurrent upstream calls at eight", async () => { | ||
| 903 | + const gates = Array.from({ length: 9 }, () => Promise.withResolvers<PrimDepartureRelayResponse>()); | ||
| 904 | + let relayCalls = 0; | ||
| 905 | + let relayPeak = 0; | ||
| 906 | + let relayActive = 0; | ||
| 907 | + const resolver = busResolver(async () => { | ||
| 908 | + relayCalls += 1; | ||
| 909 | + relayActive += 1; | ||
| 910 | + relayPeak = Math.max(relayPeak, relayActive); | ||
| 911 | + try { | ||
| 912 | + return await gates[relayCalls - 1]!.promise; | ||
| 913 | + } finally { | ||
| 914 | + relayActive -= 1; | ||
| 915 | + } | ||
| 916 | + }); | ||
| 917 | + | ||
| 918 | + const results = Array.from( | ||
| 919 | + { length: 9 }, | ||
| 920 | + () => resolver({ serviceId: BUS_SERVICE, apiKey: KEY_A }), | ||
| 921 | + ); | ||
| 922 | + await flush(); | ||
| 923 | + assert.equal(relayCalls, 8); | ||
| 924 | + assert.equal(relayPeak, 8); | ||
| 925 | + | ||
| 926 | + const payload = relayJson(await loadFixture("bus")); | ||
| 927 | + for (const gate of gates) gate.resolve(payload); | ||
| 928 | + await Promise.all(results); | ||
| 929 | + assert.equal(relayPeak, 8); | ||
| 930 | +}); | ||
| 931 | + | ||
| 932 | +test("unknown services settle INVALID_SERVICE at the handler without caching or upstream work", async () => { | ||
| 933 | + const state = createDepartureRuntimeState(); | ||
| 934 | + const { seen, resolve } = recordingResolver(async () => errorSnapshot("INVALID_SERVICE", CLOCK_SEC)); | ||
| 935 | + const first = expectError(await requestDepartures({ | ||
| 936 | + serviceId: "ghost-service", | ||
| 937 | + requestId: "ghost-1", | ||
| 938 | + state, | ||
| 939 | + resolve, | ||
| 940 | + nowMilliseconds: () => CLOCK_MS, | ||
| 941 | + })); | ||
| 942 | + assert.deepEqual(first, { | ||
| 943 | + schemaVersion: SCHEMA_VERSION, | ||
| 944 | + requestId: "ghost-1", | ||
| 945 | + favoriteId: "dep-favorite", | ||
| 946 | + code: "INVALID_SERVICE", | ||
| 947 | + occurredAt: CLOCK_SEC, | ||
| 948 | + }); | ||
| 949 | + assert.equal(seen.length, 1); | ||
| 950 | + assert.equal(state.cache.size, 0); | ||
| 951 | + | ||
| 952 | + await requestDepartures({ serviceId: "ghost-service", state, resolve }); | ||
| 953 | + assert.equal(seen.length, 2); | ||
| 954 | +}); | ||
| 955 | + | ||
| 956 | +test("cache is service-keyed, credential-free, and fresh for exactly 60 seconds with source timestamps retained", async () => { | ||
| 957 | + const state = createDepartureRuntimeState(); | ||
| 958 | + let now = CLOCK_MS; | ||
| 959 | + let resolverCalls = 0; | ||
| 960 | + const snapshots = [snapshot(CLOCK_SEC), snapshot(CLOCK_SEC + 60), snapshot(CLOCK_SEC + 120)]; | ||
| 961 | + const resolve = async () => { | ||
| 962 | + resolverCalls += 1; | ||
| 963 | + return snapshots[resolverCalls - 1]!; | ||
| 964 | + }; | ||
| 965 | + const call = (requestId: string, apiKey: string) => | ||
| 966 | + requestDepartures({ | ||
| 967 | + serviceId: "cache-service", | ||
| 968 | + requestId, | ||
| 969 | + apiKey, | ||
| 970 | + state, | ||
| 971 | + resolve, | ||
| 972 | + nowMilliseconds: () => now, | ||
| 973 | + }); | ||
| 974 | + | ||
| 975 | + const initial = expectDeparture(await call("cache-1", KEY_A)); | ||
| 976 | + assert.equal(initial.fetchedAt, CLOCK_SEC); | ||
| 977 | + assert.equal(initial.sourceUpdatedAt, CLOCK_SEC - 10); | ||
| 978 | + assert.deepEqual([...state.cache.keys()], ["cache-service"]); | ||
| 979 | + assert.equal(JSON.stringify([...state.cache.values()]).includes(KEY_A), false); | ||
| 980 | + assert.equal(JSON.stringify([...state.cache.values()]).includes(KEY_B), false); | ||
| 981 | + | ||
| 982 | + now = CLOCK_MS + CACHE_FRESH_SECONDS * 1_000 - 1; | ||
| 983 | + const fresh = expectDeparture(await call("cache-2", KEY_B)); | ||
| 984 | + assert.equal(resolverCalls, 1); | ||
| 985 | + assert.equal(fresh.fetchedAt, CLOCK_SEC); | ||
| 986 | + assert.equal(fresh.sourceUpdatedAt, CLOCK_SEC - 10); | ||
| 987 | + | ||
| 988 | + now = CLOCK_MS + CACHE_FRESH_SECONDS * 1_000; | ||
| 989 | + const expired = expectDeparture(await call("cache-3", KEY_B)); | ||
| 990 | + assert.equal(resolverCalls, 2); | ||
| 991 | + assert.equal(expired.fetchedAt, CLOCK_SEC + 60); | ||
| 992 | + | ||
| 993 | + now = CLOCK_MS; | ||
| 994 | + const backwards = expectDeparture(await call("cache-4", KEY_B)); | ||
| 995 | + assert.equal(resolverCalls, 3); | ||
| 996 | + assert.equal(backwards.fetchedAt, CLOCK_SEC + 120); | ||
| 997 | +}); | ||
| 998 | + | ||
| 999 | +test("cache hits accept any syntactically valid non-empty key and never validate upstream", async () => { | ||
| 1000 | + const state = createDepartureRuntimeState(); | ||
| 1001 | + const metrics = metricsRecorder(); | ||
| 1002 | + let resolverCalls = 0; | ||
| 1003 | + const resolve = async () => { | ||
| 1004 | + resolverCalls += 1; | ||
| 1005 | + if (resolverCalls === 1) return snapshot(CLOCK_SEC); | ||
| 1006 | + throw new Error(`upstream must not run for cache hits (${KEY_A})`); | ||
| 1007 | + }; | ||
| 1008 | + await requestDepartures({ | ||
| 1009 | + serviceId: "warm-service", | ||
| 1010 | + apiKey: KEY_A, | ||
| 1011 | + state, | ||
| 1012 | + resolve, | ||
| 1013 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1014 | + }); | ||
| 1015 | + | ||
| 1016 | + const hit = expectDeparture(await requestDepartures({ | ||
| 1017 | + serviceId: "warm-service", | ||
| 1018 | + requestId: "warm-2", | ||
| 1019 | + apiKey: "x", | ||
| 1020 | + state, | ||
| 1021 | + resolve, | ||
| 1022 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1023 | + metrics, | ||
| 1024 | + })); | ||
| 1025 | + assert.equal(hit.requestId, "warm-2"); | ||
| 1026 | + assert.deepEqual(metrics.entries.map((entry) => entry.name).sort(), [ | ||
| 1027 | + DEPARTURE_METRIC_NAME.CACHE_HIT, | ||
| 1028 | + DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES, | ||
| 1029 | + ]); | ||
| 1030 | + const cached = state.cache.get("warm-service")!; | ||
| 1031 | + assert.equal( | ||
| 1032 | + metrics.entries.find((entry) => entry.name === DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES)?.value, | ||
| 1033 | + utf8Bytes(JSON.stringify(cached.data)), | ||
| 1034 | + ); | ||
| 1035 | +}); | ||
| 1036 | + | ||
| 1037 | +test("cache hits return fresh per-caller bindings and defensive copies", async () => { | ||
| 1038 | + const state = createDepartureRuntimeState(); | ||
| 1039 | + const resolve = async () => snapshot(CLOCK_SEC); | ||
| 1040 | + await requestDepartures({ | ||
| 1041 | + serviceId: "bind-service", | ||
| 1042 | + apiKey: KEY_A, | ||
| 1043 | + state, | ||
| 1044 | + resolve, | ||
| 1045 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1046 | + }); | ||
| 1047 | + | ||
| 1048 | + const first = expectDeparture(await requestDepartures({ | ||
| 1049 | + serviceId: "bind-service", | ||
| 1050 | + requestId: "bind-1", | ||
| 1051 | + favoriteId: "fav-1", | ||
| 1052 | + apiKey: KEY_A, | ||
| 1053 | + state, | ||
| 1054 | + resolve, | ||
| 1055 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1056 | + })); | ||
| 1057 | + const second = expectDeparture(await requestDepartures({ | ||
| 1058 | + serviceId: "bind-service", | ||
| 1059 | + requestId: "bind-2", | ||
| 1060 | + favoriteId: "fav-2", | ||
| 1061 | + apiKey: KEY_B, | ||
| 1062 | + state, | ||
| 1063 | + resolve, | ||
| 1064 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1065 | + })); | ||
| 1066 | + | ||
| 1067 | + assert.equal(first.requestId, "bind-1"); | ||
| 1068 | + assert.equal(first.favoriteId, "fav-1"); | ||
| 1069 | + assert.equal(second.requestId, "bind-2"); | ||
| 1070 | + assert.equal(second.favoriteId, "fav-2"); | ||
| 1071 | + assert.notEqual(first.departures, second.departures); | ||
| 1072 | + | ||
| 1073 | + first.departures[0]!.minutes = 999; | ||
| 1074 | + first.departures.push({ | ||
| 1075 | + expectedAt: CLOCK_SEC + 999, | ||
| 1076 | + minutes: 999, | ||
| 1077 | + status: "CANCELLED", | ||
| 1078 | + }); | ||
| 1079 | + assert.equal(second.departures[0]!.minutes, 2); | ||
| 1080 | + assert.equal(second.departures.length, 2); | ||
| 1081 | + assert.equal(state.cache.get("bind-service")!.data.departures[0]!.minutes, 2); | ||
| 1082 | +}); | ||
| 1083 | + | ||
| 1084 | +test("concurrent misses on one service single-flight upstream and bind every caller", async () => { | ||
| 1085 | + const state = createDepartureRuntimeState(); | ||
| 1086 | + const metrics = metricsRecorder(); | ||
| 1087 | + const gate = Promise.withResolvers<PublicDepartureData>(); | ||
| 1088 | + const { seen, resolve } = recordingResolver(async () => gate.promise); | ||
| 1089 | + | ||
| 1090 | + const calls = [KEY_A, KEY_B, KEY_C].map((apiKey, index) => | ||
| 1091 | + requestDepartures({ | ||
| 1092 | + serviceId: "flight-service", | ||
| 1093 | + requestId: `flight-${index}`, | ||
| 1094 | + favoriteId: `flight-fav-${index}`, | ||
| 1095 | + apiKey, | ||
| 1096 | + state, | ||
| 1097 | + resolve, | ||
| 1098 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1099 | + metrics, | ||
| 1100 | + })); | ||
| 1101 | + | ||
| 1102 | + assert.equal(seen.length, 1); | ||
| 1103 | + assert.deepEqual(seen[0], { serviceId: "flight-service", apiKey: KEY_A }); | ||
| 1104 | + assert.equal(state.flights.get("flight-service")?.waiters.length, 2); | ||
| 1105 | + | ||
| 1106 | + gate.resolve(snapshot(CLOCK_SEC)); | ||
| 1107 | + const results = await Promise.all(calls); | ||
| 1108 | + results.forEach((result, index) => { | ||
| 1109 | + const departure = expectDeparture(result); | ||
| 1110 | + assert.equal(departure.requestId, `flight-${index}`); | ||
| 1111 | + assert.equal(departure.favoriteId, `flight-fav-${index}`); | ||
| 1112 | + assert.equal(departure.fetchedAt, CLOCK_SEC); | ||
| 1113 | + }); | ||
| 1114 | + assert.equal(seen.length, 1); | ||
| 1115 | + assert.equal(state.flights.size, 0); | ||
| 1116 | + assert.deepEqual([...state.cache.keys()], ["flight-service"]); | ||
| 1117 | + assert.equal(JSON.stringify(results).includes(KEY_A), false); | ||
| 1118 | + assert.equal(JSON.stringify(results).includes(KEY_C), false); | ||
| 1119 | + | ||
| 1120 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.CACHE_MISS), 3); | ||
| 1121 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.COALESCED_REQUEST), 2); | ||
| 1122 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 0); | ||
| 1123 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_STATUS), 0); | ||
| 1124 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS), 0); | ||
| 1125 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.NORMALIZED_RESPONSE_BYTES), 3); | ||
| 1126 | + for (const entry of metrics.entries) { | ||
| 1127 | + assert.ok(Object.values(DEPARTURE_METRIC_NAME).includes(entry.name)); | ||
| 1128 | + assert.equal(JSON.stringify(entry).includes(KEY_A), false); | ||
| 1129 | + } | ||
| 1130 | +}); | ||
| 1131 | + | ||
| 1132 | +test("a same-key credential failure settles the whole in-flight queue from one upstream call", async () => { | ||
| 1133 | + const state = createDepartureRuntimeState(); | ||
| 1134 | + const gate = Promise.withResolvers<PublicDepartureError>(); | ||
| 1135 | + const { seen, resolve } = recordingResolver(async () => gate.promise); | ||
| 1136 | + | ||
| 1137 | + const calls = [0, 1].map((index) => | ||
| 1138 | + requestDepartures({ | ||
| 1139 | + serviceId: "invalid-key-service", | ||
| 1140 | + requestId: `invalid-${index}`, | ||
| 1141 | + favoriteId: `invalid-fav-${index}`, | ||
| 1142 | + apiKey: KEY_A, | ||
| 1143 | + state, | ||
| 1144 | + resolve, | ||
| 1145 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1146 | + })); | ||
| 1147 | + | ||
| 1148 | + gate.resolve(errorSnapshot("API_KEY_INVALID", CLOCK_SEC, 120)); | ||
| 1149 | + const results = await Promise.all(calls); | ||
| 1150 | + results.forEach((result, index) => { | ||
| 1151 | + assert.deepEqual(result, { | ||
| 1152 | + schemaVersion: SCHEMA_VERSION, | ||
| 1153 | + requestId: `invalid-${index}`, | ||
| 1154 | + favoriteId: `invalid-fav-${index}`, | ||
| 1155 | + code: "API_KEY_INVALID", | ||
| 1156 | + occurredAt: CLOCK_SEC, | ||
| 1157 | + retryAfterSeconds: 120, | ||
| 1158 | + }); | ||
| 1159 | + }); | ||
| 1160 | + assert.equal(seen.length, 1); | ||
| 1161 | + assert.equal(state.flights.size, 0); | ||
| 1162 | + assert.equal(state.cache.size, 0); | ||
| 1163 | + | ||
| 1164 | + const again = expectError(await requestDepartures({ | ||
| 1165 | + serviceId: "invalid-key-service", | ||
| 1166 | + requestId: "invalid-2", | ||
| 1167 | + apiKey: KEY_A, | ||
| 1168 | + state, | ||
| 1169 | + resolve, | ||
| 1170 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1171 | + })); | ||
| 1172 | + assert.equal(again.code, "API_KEY_INVALID"); | ||
| 1173 | + assert.equal(seen.length, 2); | ||
| 1174 | +}); | ||
| 1175 | + | ||
| 1176 | +test("mixed-key failures settle failed-key waiters and promote exactly one different-key leader without burst", async () => { | ||
| 1177 | + const state = createDepartureRuntimeState(); | ||
| 1178 | + const metrics = metricsRecorder(); | ||
| 1179 | + const gates: Array<PromiseWithResolvers<DepartureResolverResult>> = []; | ||
| 1180 | + const { seen, resolve } = recordingResolver(async () => { | ||
| 1181 | + const gate = Promise.withResolvers<DepartureResolverResult>(); | ||
| 1182 | + gates.push(gate); | ||
| 1183 | + return gate.promise; | ||
| 1184 | + }); | ||
| 1185 | + | ||
| 1186 | + const calls = [ | ||
| 1187 | + { apiKey: KEY_A, requestId: "mix-a1", favoriteId: "mix-fav-a1" }, | ||
| 1188 | + { apiKey: KEY_A, requestId: "mix-a2", favoriteId: "mix-fav-a2" }, | ||
| 1189 | + { apiKey: KEY_B, requestId: "mix-b", favoriteId: "mix-fav-b" }, | ||
| 1190 | + { apiKey: KEY_C, requestId: "mix-c", favoriteId: "mix-fav-c" }, | ||
| 1191 | + ].map((input) => | ||
| 1192 | + requestDepartures({ | ||
| 1193 | + serviceId: "mixed-service", | ||
| 1194 | + ...input, | ||
| 1195 | + state, | ||
| 1196 | + resolve, | ||
| 1197 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1198 | + metrics, | ||
| 1199 | + })); | ||
| 1200 | + | ||
| 1201 | + assert.equal(seen.length, 1); | ||
| 1202 | + gates[0]!.resolve(errorSnapshot("API_KEY_INVALID", CLOCK_SEC)); | ||
| 1203 | + const [failedA1, failedA2] = await Promise.all(calls.slice(0, 2)); | ||
| 1204 | + | ||
| 1205 | + assert.deepEqual(failedA1, { | ||
| 1206 | + schemaVersion: SCHEMA_VERSION, | ||
| 1207 | + requestId: "mix-a1", | ||
| 1208 | + favoriteId: "mix-fav-a1", | ||
| 1209 | + code: "API_KEY_INVALID", | ||
| 1210 | + occurredAt: CLOCK_SEC, | ||
| 1211 | + }); | ||
| 1212 | + assert.deepEqual(failedA2, { | ||
| 1213 | + schemaVersion: SCHEMA_VERSION, | ||
| 1214 | + requestId: "mix-a2", | ||
| 1215 | + favoriteId: "mix-fav-a2", | ||
| 1216 | + code: "API_KEY_INVALID", | ||
| 1217 | + occurredAt: CLOCK_SEC, | ||
| 1218 | + }); | ||
| 1219 | + assert.equal(seen.length, 2); | ||
| 1220 | + assert.deepEqual(seen[1], { serviceId: "mixed-service", apiKey: KEY_B }); | ||
| 1221 | + assert.notEqual(state.flights.get("mixed-service"), undefined); | ||
| 1222 | + | ||
| 1223 | + gates[1]!.resolve(snapshot(CLOCK_SEC)); | ||
| 1224 | + const [promoted, coalesced] = await Promise.all(calls.slice(2)); | ||
| 1225 | + const promotedData = expectDeparture(promoted!); | ||
| 1226 | + const coalescedData = expectDeparture(coalesced!); | ||
| 1227 | + assert.equal(promotedData.requestId, "mix-b"); | ||
| 1228 | + assert.equal(promotedData.fetchedAt, CLOCK_SEC); | ||
| 1229 | + assert.equal(coalescedData.requestId, "mix-c"); | ||
| 1230 | + assert.equal(coalescedData.favoriteId, "mix-fav-c"); | ||
| 1231 | + assert.equal(coalescedData.fetchedAt, CLOCK_SEC); | ||
| 1232 | + | ||
| 1233 | + assert.equal(seen.length, 2); | ||
| 1234 | + assert.equal(seen.filter((request) => request.apiKey === KEY_A).length, 1); | ||
| 1235 | + assert.equal(state.flights.size, 0); | ||
| 1236 | + assert.deepEqual([...state.cache.keys()], ["mixed-service"]); | ||
| 1237 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.COALESCED_REQUEST), 3); | ||
| 1238 | + assert.equal(metrics.count(DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST), 0); | ||
| 1239 | +}); | ||
| 1240 | + | ||
| 1241 | +test("shared upstream failures settle every waiter with its own bound error and never cache", async () => { | ||
| 1242 | + const state = createDepartureRuntimeState(); | ||
| 1243 | + const { seen, resolve } = recordingResolver(async () => { | ||
| 1244 | + throw new Error(`upstream socket lost (${KEY_B})`); | ||
| 1245 | + }); | ||
| 1246 | + const keys = [KEY_A, KEY_B, KEY_C]; | ||
| 1247 | + const results = await Promise.all(keys.map((apiKey, index) => | ||
| 1248 | + requestDepartures({ | ||
| 1249 | + serviceId: "down-service", | ||
| 1250 | + requestId: `down-${index}`, | ||
| 1251 | + apiKey, | ||
| 1252 | + state, | ||
| 1253 | + resolve, | ||
| 1254 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1255 | + }))); | ||
| 1256 | + results.forEach((result, index) => { | ||
| 1257 | + assert.deepEqual(result, { | ||
| 1258 | + schemaVersion: SCHEMA_VERSION, | ||
| 1259 | + requestId: `down-${index}`, | ||
| 1260 | + favoriteId: "dep-favorite", | ||
| 1261 | + code: "SOURCE_UNAVAILABLE", | ||
| 1262 | + occurredAt: CLOCK_SEC, | ||
| 1263 | + }); | ||
| 1264 | + }); | ||
| 1265 | + assert.equal(seen.length, 1); | ||
| 1266 | + assert.equal(state.cache.size, 0); | ||
| 1267 | + assert.equal(state.flights.size, 0); | ||
| 1268 | + assert.equal(JSON.stringify(results).includes(KEY_B), false); | ||
| 1269 | + | ||
| 1270 | + const junkState = createDepartureRuntimeState(); | ||
| 1271 | + const junk = expectError(await requestDepartures({ | ||
| 1272 | + serviceId: "junk-service", | ||
| 1273 | + requestId: "junk-1", | ||
| 1274 | + apiKey: KEY_A, | ||
| 1275 | + state: junkState, | ||
| 1276 | + resolve: async () => ({ unexpected: true }) as unknown as PublicDepartureData, | ||
| 1277 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1278 | + })); | ||
| 1279 | + assert.equal(junk.code, "INVALID_RESPONSE"); | ||
| 1280 | + assert.equal(junkState.cache.size, 0); | ||
| 1281 | +}); | ||
| 1282 | + | ||
| 1283 | +test("departure HTTP boundary maps methods, paths, and every error code to frozen statuses", async () => { | ||
| 1284 | + const route = DEPARTURE_ROUTE; | ||
| 1285 | + const base = { | ||
| 1286 | + state: createDepartureRuntimeState(), | ||
| 1287 | + resolve: async () => snapshot(CLOCK_SEC), | ||
| 1288 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1289 | + }; | ||
| 1290 | + const post = (overrides: { | ||
| 1291 | + readonly method?: string; | ||
| 1292 | + readonly path?: string; | ||
| 1293 | + readonly authorization?: unknown; | ||
| 1294 | + readonly body?: unknown; | ||
| 1295 | + }) => handleDepartureHttpRequest({ | ||
| 1296 | + method: overrides.method ?? "POST", | ||
| 1297 | + path: overrides.path ?? route, | ||
| 1298 | + authorization: overrides.authorization, | ||
| 1299 | + body: overrides.body ?? { | ||
| 1300 | + schemaVersion: SCHEMA_VERSION, | ||
| 1301 | + requestId: "http-ok", | ||
| 1302 | + favoriteId: "http-fav-ok", | ||
| 1303 | + serviceId: "http-service", | ||
| 1304 | + }, | ||
| 1305 | + }, base); | ||
| 1306 | + | ||
| 1307 | + const success = await post({ authorization: `Bearer ${KEY_A}` }); | ||
| 1308 | + assert.equal(success.status, 200); | ||
| 1309 | + assert.equal(isDepartureResult(success.body), true); | ||
| 1310 | + | ||
| 1311 | + const methodMismatch = await post({ method: "GET" }); | ||
| 1312 | + assert.equal(methodMismatch.status, 405); | ||
| 1313 | + assert.equal(expectError(methodMismatch.body).code, "INVALID_SERVICE"); | ||
| 1314 | + | ||
| 1315 | + const pathMismatch = await post({ path: "/api/departures/extra" }); | ||
| 1316 | + assert.equal(pathMismatch.status, 404); | ||
| 1317 | + | ||
| 1318 | + const errorCases: ReadonlyArray<{ | ||
| 1319 | + readonly expectedStatus: 400 | 401 | 429 | 502 | 503; | ||
| 1320 | + readonly expectedCode: ErrorCode; | ||
| 1321 | + readonly authorization?: unknown; | ||
| 1322 | + readonly body?: unknown; | ||
| 1323 | + readonly resolve: (request: DepartureResolverRequest) => Promise<DepartureResolverResult>; | ||
| 1324 | + }> = [ | ||
| 1325 | + { | ||
| 1326 | + expectedStatus: 401, | ||
| 1327 | + expectedCode: "API_KEY_REQUIRED", | ||
| 1328 | + resolve: base.resolve, | ||
| 1329 | + }, | ||
| 1330 | + { | ||
| 1331 | + expectedStatus: 401, | ||
| 1332 | + expectedCode: "API_KEY_INVALID", | ||
| 1333 | + authorization: `Token ${KEY_A}`, | ||
| 1334 | + resolve: base.resolve, | ||
| 1335 | + }, | ||
| 1336 | + { | ||
| 1337 | + expectedStatus: 400, | ||
| 1338 | + expectedCode: "INVALID_SERVICE", | ||
| 1339 | + authorization: `Bearer ${KEY_A}`, | ||
| 1340 | + body: { schemaVersion: SCHEMA_VERSION }, | ||
| 1341 | + resolve: base.resolve, | ||
| 1342 | + }, | ||
| 1343 | + { | ||
| 1344 | + expectedStatus: 429, | ||
| 1345 | + expectedCode: "RATE_LIMITED", | ||
| 1346 | + authorization: `Bearer ${KEY_A}`, | ||
| 1347 | + resolve: async () => errorSnapshot("RATE_LIMITED", CLOCK_SEC, 30), | ||
| 1348 | + }, | ||
| 1349 | + { | ||
| 1350 | + expectedStatus: 503, | ||
| 1351 | + expectedCode: "SOURCE_UNAVAILABLE", | ||
| 1352 | + authorization: `Bearer ${KEY_A}`, | ||
| 1353 | + resolve: async () => { | ||
| 1354 | + throw new Error("downstream gone"); | ||
| 1355 | + }, | ||
| 1356 | + }, | ||
| 1357 | + { | ||
| 1358 | + expectedStatus: 502, | ||
| 1359 | + expectedCode: "INVALID_RESPONSE", | ||
| 1360 | + authorization: `Bearer ${KEY_A}`, | ||
| 1361 | + resolve: async () => ({ junk: true }) as unknown as PublicDepartureData, | ||
| 1362 | + }, | ||
| 1363 | + ]; | ||
| 1364 | + for (const [index, errorCase] of errorCases.entries()) { | ||
| 1365 | + const response = await handleDepartureHttpRequest({ | ||
| 1366 | + method: "POST", | ||
| 1367 | + path: route, | ||
| 1368 | + authorization: errorCase.authorization, | ||
| 1369 | + body: errorCase.body ?? { | ||
| 1370 | + schemaVersion: SCHEMA_VERSION, | ||
| 1371 | + requestId: `http-err-${index}`, | ||
| 1372 | + favoriteId: `http-fav-${index}`, | ||
| 1373 | + serviceId: `http-err-service-${index}`, | ||
| 1374 | + }, | ||
| 1375 | + }, { | ||
| 1376 | + state: createDepartureRuntimeState(), | ||
| 1377 | + resolve: errorCase.resolve, | ||
| 1378 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1379 | + }); | ||
| 1380 | + assert.equal(response.status, errorCase.expectedStatus, `case ${errorCase.expectedCode}`); | ||
| 1381 | + const body = expectError(response.body); | ||
| 1382 | + assert.equal(body.code, errorCase.expectedCode); | ||
| 1383 | + if (errorCase.expectedCode === "RATE_LIMITED") { | ||
| 1384 | + assert.equal(body.retryAfterSeconds, 30); | ||
| 1385 | + } | ||
| 1386 | + assert.equal(JSON.stringify(body).includes(KEY_A), false); | ||
| 1387 | + } | ||
| 1388 | + | ||
| 1389 | + const rateLimited = expectError((await handleDepartureHttpRequest({ | ||
| 1390 | + method: "POST", | ||
| 1391 | + path: route, | ||
| 1392 | + authorization: `Bearer ${KEY_A}`, | ||
| 1393 | + body: { | ||
| 1394 | + schemaVersion: SCHEMA_VERSION, | ||
| 1395 | + requestId: "http-429", | ||
| 1396 | + favoriteId: "http-fav-429", | ||
| 1397 | + serviceId: "http-429-service", | ||
| 1398 | + }, | ||
| 1399 | + }, { | ||
| 1400 | + state: createDepartureRuntimeState(), | ||
| 1401 | + resolve: async () => errorSnapshot("RATE_LIMITED", CLOCK_SEC, 30), | ||
| 1402 | + nowMilliseconds: () => CLOCK_MS, | ||
| 1403 | + })).body); | ||
| 1404 | + assert.equal(departureHttpStatus(rateLimited), 429); | ||
| 1405 | +}); | ||
| 1406 | + | ||
| 1407 | +test("representative cache-hit loop keeps p95 backend latency at or below 200 ms", async () => { | ||
| 1408 | + const state = createDepartureRuntimeState(); | ||
| 1409 | + let resolverCalls = 0; | ||
| 1410 | + const resolve = async () => { | ||
| 1411 | + resolverCalls += 1; | ||
| 1412 | + if (resolverCalls === 1) return snapshot(CLOCK_SEC); | ||
| 1413 | + throw new Error("upstream must never run in the cache-hit loop"); | ||
| 1414 | + }; | ||
| 1415 | + expectDeparture(await requestDepartures({ | ||
| 1416 | + serviceId: "p95-service", | ||
| 1417 | + requestId: "p95-warm", | ||
| 1418 | + apiKey: KEY_A, | ||
| 1419 | + state, | ||
| 1420 | + resolve, | ||
| 1421 | + })); | ||
| 1422 | + | ||
| 1423 | + const durations: number[] = []; | ||
| 1424 | + for (let index = 0; index < 1000; index += 1) { | ||
| 1425 | + const startedAt = performance.now(); | ||
| 1426 | + const result = await requestDepartures({ | ||
| 1427 | + serviceId: "p95-service", | ||
| 1428 | + requestId: `p95-${index}`, | ||
| 1429 | + apiKey: KEY_A, | ||
| 1430 | + state, | ||
| 1431 | + resolve, | ||
| 1432 | + }); | ||
| 1433 | + durations.push(performance.now() - startedAt); | ||
| 1434 | + expectDeparture(result); | ||
| 1435 | + } | ||
| 1436 | + durations.sort((left, right) => left - right); | ||
| 1437 | + const p95 = durations[Math.ceil(durations.length * 0.95) - 1]!; | ||
| 1438 | + assert.ok( | ||
| 1439 | + p95 <= 200, | ||
| 1440 | + `cache-hit p95 ${p95.toFixed(3)}ms exceeds the 200ms budget`, | ||
| 1441 | + ); | ||
| 1442 | +}); | ||
modified
scripts/test.mjs +2 -0 | @@ -10,6 +10,8 @@ run(process.execPath, [ | ||
| 10 | 10 | "--test", |
| 11 | 11 | "packages/contracts/test/contracts.test.ts", |
| 12 | 12 | "packages/backend/test/backend.test.ts", |
| 13 | + "packages/backend/test/departures.test.ts", | |
| 14 | + "packages/backend/test/departure-endpoint.test.ts", | |
| 13 | 15 | "packages/backend/test/catalog.test.ts", |
| 14 | 16 | "packages/backend/test/catalog-import.test.ts", |
| 15 | 17 | "packages/backend/test/catalog-http.test.ts", |
| @@ -10,6 +10,8 @@ run(process.execPath, [ | |||
| 10 | "--test", | 10 | "--test", |
| 11 | "packages/contracts/test/contracts.test.ts", | 11 | "packages/contracts/test/contracts.test.ts", |
| 12 | "packages/backend/test/backend.test.ts", | 12 | "packages/backend/test/backend.test.ts", |
| 13 | + "packages/backend/test/departures.test.ts", | ||
| 14 | + "packages/backend/test/departure-endpoint.test.ts", | ||
| 13 | "packages/backend/test/catalog.test.ts", | 15 | "packages/backend/test/catalog.test.ts", |
| 14 | "packages/backend/test/catalog-import.test.ts", | 16 | "packages/backend/test/catalog-import.test.ts", |
| 15 | "packages/backend/test/catalog-http.test.ts", | 17 | "packages/backend/test/catalog-http.test.ts", |