Files
Flights/JSX_NOTES.md
2026-04-11 13:55:12 -05:00

21 KiB
Raw Permalink Blame History

JSX / JetSuiteX — Reverse Engineering Notes

How to extract per-flight loads from jsx.com from an iOS app (WKWebView) or a command-line harness (Playwright). Findings from debugging this on real hardware against the live production site in April 2026.


TL;DR

  1. JSX's website runs on Navitaire. The data you want (seats available per fare class, prices per class, all flights for the day) lives in the POST /api/nsk/v4/availability/search/simple response body.
  2. You cannot call that endpoint from outside a loaded jsx.com page — Akamai will reject the request with an HTTP/2 protocol error based on the TLS fingerprint of whatever's making the call.
  3. You can call it from inside a loaded jsx.com page, using the browser's own fetch() with the anonymous token the SPA stashes in sessionStorage["navitaire.digital.token"]. Same-origin + browser TLS + browser cookies means Akamai sees it as a normal user request.
  4. Driving the on-page UI to submit the search form is a dead end in WKWebView (but works in Playwright) because of a subtle trusted-event issue described below.

The working pattern is therefore:

Navigate browser → jsx.com    (real page load)
Wait for SPA bootstrap        (station buttons exist, token call finishes)
Read token from sessionStorage
POST /api/nsk/v4/availability/search/simple  ← from inside the page
Parse the response

Endpoints

Base: https://api.jsx.com

Method Path Purpose
POST /api/nsk/v2/token Anonymous auth token. Fired by the SPA automatically on page load. Response body is { data: { token: "eyJhbGci...", idleTimeoutInMinutes: 15 } }.
POST /api/v2/graph/primaryResources Route network / station list. SPA bootstrap.
POST /api/v2/graph/secondaryResources Extra static resources. SPA bootstrap.
POST /api/v2/graph/setCulture Locale setup. SPA bootstrap.
GET /api/nsk/v1/resources/contents?Type=GeneralReference Reference data. SPA bootstrap.
GET /api/nsk/v1/availability/lowfare/estimate?origin=X&destination=Y&... Per-day cheapest fare for a route across a date range. Fires when the user selects origin+destination. Useful as a warm-up / liveness check.
POST /api/nsk/v4/availability/search/simple The data you want. Fired when the user clicks Find Flights. Contains every flight for the route/date plus per-fare-class prices and counts.
PUT /api/nsk/v2/token Token refresh. Happens after a successful search.

All endpoints require the browser's Akamai cookies from a real jsx.com page load. The search/simple endpoint additionally requires the Authorization header set to the token (raw, no Bearer prefix — just the JWT string).


Auth

The SPA stores its anonymous token in sessionStorage["navitaire.digital.token"] as JSON:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "idleTimeoutInMinutes": 15
}

Read it from inside the page:

const raw = sessionStorage.getItem("navitaire.digital.token");
const token = raw ? (JSON.parse(raw).token || "") : "";

Wait up to ~3 seconds after page load for it to appear — the POST /api/nsk/v2/token call that populates it is async.

Use it as the Authorization header value on the search/simple POST:

fetch("https://api.jsx.com/api/nsk/v4/availability/search/simple", {
    method: "POST",
    credentials: "include",
    headers: {
        "Content-Type": "application/json",
        "Accept": "application/json, text/plain, */*",
        "Authorization": token
    },
    body: JSON.stringify(requestBody)
});

Request body (/availability/search/simple)

Byte-identical to what the JSX website's own search() method posts when its form is valid. Confirmed by intercepting the real call in Playwright.

{
  "beginDate": "2026-04-15",
  "destination": "HOU",
  "origin": "DAL",
  "passengers": { "types": [{ "count": 1, "type": "ADT" }] },
  "taxesAndFees": 2,
  "filters": {
    "maxConnections": 4,
    "compressionType": 1,
    "sortOptions": [4],
    "fareTypes": ["R"],
    "exclusionType": 2
  },
  "numberOfFaresPerJourney": 10,
  "codes": { "currencyCode": "USD" },
  "ssrCollectionsMode": 1
}

beginDate is YYYY-MM-DD. No endDate needed for one-way. origin and destination are IATA codes.


Response shape (/availability/search/simple)

The response nests per-flight data inside a by-market dict, with fare pricing split into a separate top-level dict that you join via key. Here's the bit that matters:

data
├── results[]
│   └── trips[]
│       ├── date: "2026-04-15T00:00:00"
│       └── journeysAvailableByMarket
│           └── "DAL|HOU": [           ← key is `${origin}|${destination}`
│                 {
│                   stops: 0,
│                   designator: { origin, destination, departure, arrival },
│                   segments: [
│                     {
│                       identifier: { carrierCode: "XE", identifier: "280" },
│                       designator: { origin, destination, departure, arrival },
│                       legs: [{
│                         legInfo: { equipmentType: "ER4", ... }
│                       }]
│                     }
│                   ],
│                   fares: [
│                     {
│                       fareAvailabilityKey: "MH5Rfn5YRX5R...",
│                       details: [{ availableCount: 1, status: 1, ... }]
│                     }
│                   ]
│                 },
│                 ... more journeys
│               ]
└── faresAvailable                     ← keyed by fareAvailabilityKey
    └── "MH5Rfn5YRX5R...": {
          totals: { fareTotal: 339, revenueTotal: 329.2, ... },
          fares: [
            {
              classOfService: "Q",
              productClass: "HO",      ← HO = Hop-On, AI = All-In
              fareBasisCode: "Q00AHOXA",
              passengerFares: [{ passengerType: "ADT", fareAmount: 339, ... }]
            }
          ]
        }

To build a per-flight load summary, for each journey in journeysAvailableByMarket[<market>]:

  1. Flight number = segments[0].identifier.carrierCode + segments[0].identifier.identifier (e.g. "XE" + "280""XE280"). For multi-segment journeys concatenate all segments' identifiers.
  2. Origin / destination / times come from journey.designator (or fall back to segments[0].designator / segments[last].designator).
  3. Equipment type is on segments[0].legs[0].legInfo.equipmentType.
  4. For each entry in journey.fares[], look up fareAvailabilityKey in data.faresAvailable. From that record:
    • totals.fareTotal = price including taxes
    • totals.revenueTotal = base fare
    • fares[0].classOfService = single-letter fare code
    • fares[0].productClass = "HO" (Hop-On) or "AI" (All-In)
  5. Sum details[].availableCount for that fare bucket — that's the seats sellable at that price point.
  6. Total sellable seats for the flight = sum across all fare buckets on that journey.
  7. Lowest price = min of all fareTotal values.

JSX flies ERJ-145 and Embraer 135 variants with ~30 seats configured 1x2. availableCount numbers in the single digits are normal.

Example parsed output for DAL → HOU on a given day:

XE280  DAL→HOU  07:15→08:30  stops=0  seats=8  from=$317
    class=N bundle=HO  count=4  fare=$317  rev=$307.2
    class=N bundle=AI  count=4  fare=$479  rev=$469.2
XE292  DAL→HOU  10:35→11:50  stops=0  seats=2  from=$409
    class=W bundle=HO  count=1  fare=$409  rev=$399.2
    class=W bundle=AI  count=1  fare=$649  rev=$639.2

The jsx.com SPA UI — what the user does

This is only relevant if you're driving the UI (not doing a direct API call). Useful for smoke testing that the page is functional and the anonymous token is fresh, but see the WKWebView caveats below.

Structure of the one-way search form at https://www.jsx.com/home/search:

  1. Trip type — a Material mat-select with combobox role. Options: "Round Trip", "One Way", "Multi City". Default is Round Trip.
  2. Origin stationbutton[aria-label='Station select'] (index 0). Opens a dropdown with a search input (placeholder "Airport or city") and a list of li[role='option'].station-options__item entries.
  3. Destination stationbutton[aria-label='Station select'] (index 1). Same structure as origin.
  4. Depart dateinput[aria-label='Depart Date'] opens a custom two-month range picker overlay on click.
  5. Return date — only visible when trip type is Round Trip.
  6. Passengers — defaults to 1 adult, fine to leave alone.
  7. Find Flights button — submits; Angular's search() handler POSTs /availability/search/simple if the form validates.

The custom datepicker

JSX does not use Angular Material's mat-calendar. It's a custom component and none of the standard Material selectors match:

  • mat-calendar, .mat-calendar, mat-datepicker-content, [class*='mat-datepicker'] — all return null.
  • .mat-calendar-period-button — doesn't exist. There's no period button to parse "April 2026" out of.
  • .mat-calendar-body-cell — doesn't exist. Day cells are something else entirely.

What does work:

  • The picker mounts under .cdk-overlay-container in a pane that visibly contains month-name text (the Angular CDK overlay system is Material-ish even if the picker isn't).
  • Day cells carry aria-label attributes in the format "Saturday, April 11, 2026"weekday prefix included. That's why an exact match against "April 11, 2026" fails but a loose contains-month-AND-year-AND-day-word-boundary match succeeds.
  • Two months are shown side by side (e.g. April + May in April). Clicking a day cell doesn't close the picker.
  • There's a "DONE" button in the bottom right of the picker. It must be clicked to commit the selection. Without it, the picker stays open and the depart-date input value isn't set (when the picker is open there are two depart-date inputs in the DOM; after DONE there's one).

The picker renders in two phases

First phase ~150400 ms after click: overlay shell with month-name text (so selectors matching on month-name text succeed). Second phase a few hundred ms later: the actual day cells with aria-labels become queryable.

If you have separate script evaluations for "open picker" and "click day cell" (i.e. cross-bridge calls in WKWebView), the second call can race the second phase and see no cells. Always poll for at least one [aria-label] matching /\b(January|…|December)\s+\d{1,2},?\s+\d{4}/ before trying to click a specific cell. Polling timeout of ~5 seconds is sufficient.

Mat-select One Way option

The trip-type combobox needs a multi-strategy opener. None of these alone is reliable across browser engines:

  1. Plain .click() on the combobox
  2. focus() + keydown of Enter, Space, or ArrowDown
  3. Walk __ngContext__ and call the Angular mat-select instance's .open() method

Once a visible mat-option appears, click the "One Way" option with the full event sequence (pointerdownmousedownpointerupmouseupclickelement.click()). If the return-date input is still visible after that, fall back to walking __ngContext__ on the mat-option for _selectViaInteraction() or select() and calling it directly.

Station picker

  • Click the button[aria-label='Station select'] at the desired index (0 = origin, 1 = destination).
  • The dropdown renders li[role='option'].station-options__item entries plus a search input[placeholder='Airport or city'].
  • Type the IATA code into the search input via the native value setter + input/change events, wait ~500 ms for filtering, then click the option whose text contains the code at a word boundary.
  • Fall back to __ngContext__._selectViaInteraction() / .select() if the click doesn't update the station button's text.

JSX embeds Osano. The banner is a div[role="dialog"] with class osano-cm-window / osano-cm-dialog. Two problems:

  1. It intercepts clicks on the page behind it.
  2. Its role="dialog" matches any selector that looks for open dialogs, so code trying to find the datepicker via [role='dialog'] will match Osano instead.

Fix: after accepting it (class-based: .osano-cm-accept-all, .osano-cm-accept; or text-based: "Accept", "Accept All", "I Agree", "Got it"), force-remove the banner node:

document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach(el => el.remove());

Find Flights button

Visible on the main form. Angular click handler calls search(), which:

  1. Checks form.invalid. If true, marks touched and returns without firing a request.
  2. Otherwise builds the request body (same shape documented above) and POSTs /availability/search/simple.

The button being visually enabled does not mean Angular thinks the form is valid — Angular rechecks on every click. If search() silently bails, it won't surface any error to the DOM.


WKWebView vs Playwright — the trusted-events trap

This is the single most important finding from this session.

The symptom

In WKWebView, after driving the entire UI (one-way, origin, destination, day cell, DONE button), the depart-date input visually shows "Sat, Apr 11" and the Find Flights button is visually enabled. Clicking it fires the click handler (you can prove it with a capture listener), but no POST to /availability/search/simple happens — ever. The only network call that fires after the click is the token refresh that was already scheduled.

The cause

WKWebView's synthetic DOM events produced by element.dispatchEvent(new MouseEvent('click', {...})) have event.isTrusted === false. JSX's custom datepicker only commits its day-cell selection into its Angular FormControl on a trusted user gesture. So the synthetic click on a day cell visually highlights the cell, lets DONE close the picker, and updates the input's display value — but the underlying FormControl stays null.

When Find Flights is then clicked, the handler runs search(), which does:

search() {
    if (this.form.invalid) {
        this.form.markAllAsTouched();
        return;
    }
    this.api.post('/availability/search/simple', this.buildBody());
}

Form is invalid because departDate FormControl is null, so the method returns early. No POST. Silent failure.

Why Playwright doesn't hit this

Playwright's page.click() and locator.click() don't produce synthetic DOM events — they go through CDP's Input.dispatchMouseEvent, which produces events indistinguishable from real user input, including isTrusted === true. JSX's picker accepts those and commits the date.

Why Angular's markAllAsTouched workaround doesn't help

You can walk __ngContext__ on every element looking for FormControl instances and call markAllAsTouched() + updateValueAndValidity(). This does trigger revalidation and reveals all the currently-invalid fields, but it doesn't populate the empty date control — it just marks it dirty. The underlying value is still null because nothing wrote to it.

The WKWebView workaround

Don't click Find Flights. Instead, call /api/nsk/v4/availability/search/simple directly via fetch() from the page context after reading the token out of sessionStorage. You still do the UI-driving steps (for page warm-up, session establishment, and as a smoke test that the site is functional), but the actual data fetch happens via a direct API call whose request shape matches exactly what search() would have posted.

This is what Flights/Services/JSXWebViewFetcher.swift step 17 does.


Anti-bot surface (Akamai)

JSX fronts api.jsx.com with Akamai Bot Manager. Observed behavior:

Request source Result
Plain curl, fetch from Node, any external HTTP client Blocked. Almost all endpoints return HTML challenge page or Akamai error.
Playwright's built-in chromium.launch() (both bundled chromium and channel: "chrome") GET requests succeed, but POST /availability/search/simple specifically returns ERR_HTTP2_PROTOCOL_ERROR. Playwright injects enough automation bits for Akamai to flag the TLS/H2 fingerprint.
Real Chrome spawned as a plain process + Playwright attached via chromium.connectOverCDP() Works reliably. Chrome has the expected fingerprint and Playwright is only driving it via CDP, not altering it.
WKWebView on macOS / iOS GET requests succeed. Direct POST /availability/search/simple from inside a loaded jsx.com page via fetch() also succeeds. The browser session's cookies and TLS fingerprint are trusted.
WKWebView with UI-driven Find Flights click Fails — but for an unrelated reason (the trusted-events trap above). Angular never fires the POST in the first place, so Akamai never sees it.

Observations:

  • The Akamai block is on the request fingerprint, not on specific URLs. The same binary can reach some endpoints and not others because different endpoints attract different scrutiny.
  • Cookies set by a real page load are transferable to fetch() calls made from the same page context via credentials: "include", and Akamai accepts them.
  • The lowfare/estimate endpoint is a useful liveness probe — it fires automatically when the SPA has origin+destination, and if it succeeds you know the Akamai session is good.

Network interceptor pattern

If you want to capture JSX traffic programmatically (e.g. in WKWebView or Playwright), override window.fetch and XMLHttpRequest to tee every request/response to a probe object. Two things to track separately:

  1. Initiated calls — every URL passed to fetch() or XMLHttpRequest.send(), recorded before awaiting the response.
  2. Completed calls — every call that returned a response (any status), plus an error marker on network failure.

This lets you tell apart "Angular's search() never fired a POST" (nothing in initiated calls) from "Angular fired a POST but the network rejected it" (initiated but not completed, error flag set). This distinction was critical for diagnosing the WKWebView trusted-events issue — without it, both failure modes look like "no searchSimple response seen".

See the window.__jsxProbe setup in Flights/Services/JSXWebViewFetcher.swift step 4 for a working implementation.


Per-step verification

Every step in the WKWebView flow has an action and one or more post-condition verifications. The action runs and returns success or failure; then every verification runs regardless and logs its result. This matters because a step can have an action that returns "ok" but actually left the page in a broken state — e.g. step 12 can return "datepicker open" because it found the overlay shell, while step 13's check reveals the day cells haven't rendered yet. Running all the verifications and surfacing their results per-line in the log (rather than stopping at the first failure) lets you see the full state the failing step produced, not just the first mismatch.

On failure, the step runner dumps:

  • The action's returned data fields (e.g. action.sample: [...], action.monthMatches: [...]) — any diagnostic payload the action JS chose to include gets surfaced automatically.
  • Page state: current URL, visible [role='combobox'] count, visible mat-option count, Find Flights button disabled/aria-disabled state, recently initiated and completed api.jsx.com calls, and any elements matching ng-invalid / mat-form-field-invalid / error classes so form validation errors are visible.

Code references

  • iOS runtime flow: Flights/Services/JSXWebViewFetcher.swift — the 19-step WKWebView-based flow with per-step verification. Returns JSXSearchResult { flights: [JSXFlight], rawSearchBody, error } with one JSXFlight entry per unique flight in the search response.

  • iOS call site: Flights/Services/AirlineLoadService.fetchJSXLoad — invokes the fetcher, logs every returned flight, and picks the caller-requested flight number via digit match.

  • Playwright reference implementation: scripts/jsx_playwright_search.mjs — standalone command-line harness that drives the real jsx.com SPA from a real Chrome attached via chromium.connectOverCDP(). Used as the source of truth for the UI flow and request/response shapes that the Swift rewrite mirrors.

    npx playwright install chromium        # one-time
    node scripts/jsx_playwright_search.mjs --origin DAL --destination HOU --date 2026-04-16 --headful
    

    Artifacts land in /tmp/jsx-playwright/ (raw search-simple.json, the lowfare.json low-fare estimate, and a calls.json log of every api.jsx.com URL/status the session saw).