anthodev/lapin-futepublic Fork
⑂ 5a0b437
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: normalize live PRIM departures (#12)Unverified

Cédric Anthony committed 2026-08-31T22:25:06+02:00 Browse files
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 @@
11 node_modules/
2+.env
3+.env.*
4+!.env.example
25 var/
36 packages/watch/build/
47 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 @@
11 import {
2- CACHE_FRESH_SECONDS,
32 LIMITS,
43 PRIM_ORIGIN,
5- SCHEMA_VERSION,
6- isDepartureResult,
7- isErrorResult,
84 isPersonalApiKey,
9- utf8Bytes,
10- type Departure,
11- type DepartureResult,
12- type ErrorCode,
13- type ErrorResult,
145 } from "../../contracts/src/index.ts";
156
167 export {
@@ -22,6 +13,11 @@ export {
2213 type CatalogServiceResolution,
2314 } from "./catalog.ts";
2415
16+export * from "./departures.ts";
17+export * from "./prim-departures.ts";
18+export * from "./departure-service.ts";
19+export * from "./departure-endpoint.ts";
20+
2521 export const REDACTED_SECRET = "[REDACTED]" as const;
2622
2723 export const BACKEND_ERROR_CODE = {
@@ -149,7 +145,11 @@ export function createRedactingLogger(
149145
150146 export interface PrimFetchInit {
151147 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+ }>;
153153 readonly redirect: "manual";
154154 readonly credentials: "omit";
155155 readonly cache: "no-store";
@@ -160,6 +160,9 @@ export interface PrimFetchResponse {
160160 readonly status: number;
161161 readonly redirected?: boolean;
162162 readonly type?: string;
163+ readonly headers?: {
164+ get(name: string): string | null;
165+ };
163166 readonly body: ReadableStream<Uint8Array> | null;
164167 }
165168
@@ -184,6 +187,7 @@ export interface PrimRelayRequest {
184187 export interface PrimRelayResponse {
185188 readonly status: number;
186189 readonly bytes: Uint8Array;
190+ readonly retryAfter?: string;
187191 }
188192
189193 const SYSTEM_TIMER: RelayTimer = {
@@ -340,7 +344,11 @@ async function performRelay(
340344 ): Promise<PrimRelayResponse> {
341345 const response = await fetch(target, {
342346 method: "GET",
343- headers: Object.freeze({ apikey: apiKey }),
347+ headers: Object.freeze({
348+ apikey: apiKey,
349+ accept: "application/json",
350+ "accept-encoding": "gzip",
351+ }),
344352 redirect: "manual",
345353 credentials: "omit",
346354 cache: "no-store",
@@ -357,9 +365,24 @@ async function performRelay(
357365 throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE);
358366 }
359367
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+
360382 return {
361383 status: response.status,
362384 bytes: await readBoundedBody(response.body, controller),
385+ ...(retryAfter === undefined ? {} : { retryAfter }),
363386 };
364387 }
365388
@@ -409,309 +432,3 @@ export async function relayPrimRequest(
409432 }
410433 }
411434
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
141141
142142 assert.equal(capturedTarget?.origin, PRIM_ORIGIN);
143143 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+ ]);
146154 assert.equal(capturedInit?.method, "GET");
147155 assert.equal(capturedInit?.redirect, "manual");
148156 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, [
1010 "--test",
1111 "packages/contracts/test/contracts.test.ts",
1212 "packages/backend/test/backend.test.ts",
13+ "packages/backend/test/departures.test.ts",
14+ "packages/backend/test/departure-endpoint.test.ts",
1315 "packages/backend/test/catalog.test.ts",
1416 "packages/backend/test/catalog-import.test.ts",
1517 "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",