anthodev/lapin-futepublic Fork
⑂ develop
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

Merged@github-anthodev wants to merge feature/normalize-live-departures/backend/4 into develop
Merged as 5a0b437da030.
22 files changed
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
Large diff collapsed to keep the page fast — load it when you need it.
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
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/backend/src/index.ts +34 -317
Large diff collapsed to keep the page fast — load it when you need it.
added packages/backend/src/prim-departures.ts +258 -0
Large diff collapsed to keep the page fast — load it when you need it.
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
Large diff collapsed to keep the page fast — load it when you need it.
added packages/backend/test/departures.test.ts +1442 -0
Large diff collapsed to keep the page fast — load it when you need it.
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",