Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
2121 lines (1930 loc) · 71.2 KB
/
Copy pathserver.js
File metadata and controls
2121 lines (1930 loc) · 71.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const express = require("express");
const {
createDatabase,
initializeDatabase,
statements,
DISTANCE_STATUS,
PATH_FORMAT,
MAP_DRIVER_STATUS,
} = require("./database.js");
const {
ROLES,
createAPIKey,
authenticateAPIKey,
resolveAPIKey,
APIKeyToProjectId,
} = require("./api-key-manager.js");
const os = require("os");
require("dotenv").config({ quiet: true });
const path = require("path");
const fs = require("fs");
const http = require("http");
const { Server } = require("socket.io");
const WebSocket = require("ws");
const crypto = require("crypto");
const { parseCoordinate, isValidLatLng } = require("./geo.js");
const { analyseProject } = require("./diagnostics.js");
// Every coordinate entering the system is WGS84 degrees. Rejecting here is the
// only thing standing between a typo and enforcement built on a wrong position.
const INVALID_COORDINATES =
"latitude must be a finite number within +/-90 and longitude within +/-180";
// A speed limit of 0 divides by zero in calculateViolation and in the
// congestion loop; a negative one inverts the comparison so every car passes;
// a string writes text into a REAL column. These two numbers are the entire
// input to the violation calculation, so neither is taken on trust.
const INVALID_SPEED_LIMIT =
"speed-limit must be a finite number greater than 0, in km/h";
// Zero is rejected rather than stored. An edge of zero metres enforces
// nothing - the maximum traversal time comes out at zero, so no car is ever
// slower than it - and that is exactly the silent failure the distance status
// exists to make visible. An unresolved edge says so; it does not pretend to
// be a very short road.
const INVALID_DISTANCE =
"distance must be a finite number of metres, greater than 0";
const INVALID_ENDPOINTS =
"from-node-id and to-node-id must both be nodes in this project";
// One documentation site for the whole ecosystem: the server, the console, the
// website and every distance driver all point here rather than each keeping a
// copy of the protocol that slowly stops being true.
const DOCS_URL = "https://docs.smartcheckpoints.xyz";
const port = process.env.PORT || 3000;
// Which interfaces to listen on.
//
// `server.listen(port)` with no host binds every interface, so the REST API,
// the Socket.IO channel and the driver WebSocket become reachable by anything
// on whatever network this machine happens to be attached to - with nothing
// done to ask for it and no indication that it happened. Cameras do have to
// reach the server, so loopback cannot be the answer forever; it is the
// default so that opening the server up is a decision somebody made.
const host = process.env.HOST || "127.0.0.1";
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
const boundToLoopback = LOOPBACK_HOSTS.has(host);
const app = express();
const server = http.createServer(app);
const io = new Server(server);
// --- Driver WebSocket ---
//
// Two kinds of process connect here. A distance driver answers "how far is it
// from A to B by road"; a map driver serves a map view. The server knows
// nothing about either beyond this protocol - no routing engine, no basemap,
// and no branch anywhere on which driver happens to be attached. Adding an
// eleventh distance driver must cost no change here, and the way that stays
// true is that nothing here is written for a particular one.
const wss = new WebSocket.Server({ noServer: true });
/**
* The protocol version this server speaks.
*
* A driver that names no version is speaking v1 and keeps working exactly as
* it did: it reads the node indices in a request, calls back over REST for the
* coordinates, and answers with a bare distance. v2 reads the coordinates
* inline, may return route geometry, and can say why it failed.
*/
const PROTOCOL_VERSION = 2;
/** One driver per role per project. There is no second slot for either. */
const DRIVER_ROLES = new Set(["distance", "map"]);
const DEFAULT_DRIVER_ROLE = "distance";
/** projectId -> { distance?: WebSocket, map?: WebSocket } */
const drivers = {};
/** requestId -> { resolve, reject, timeout, ws } */
const pendingDistanceRequests = {};
/** How long a driver has to answer before the request is abandoned. */
const DISTANCE_TIMEOUT_MS = 30000;
/**
* A socket that connects and never authenticates would otherwise live forever,
* costing a file descriptor and telling nobody anything.
*/
const AUTH_TIMEOUT_MS = 10000;
/**
* Ping every interval; terminate after two go unanswered.
*
* A driver killed outright leaves a socket that TCP will not notice for a very
* long time, and that socket holds its project's only slot for all of it. Two
* missed pings frees it inside about ninety seconds.
*/
const HEARTBEAT_INTERVAL_MS = 30000;
const MAX_MISSED_PONGS = 2;
const CLOSE_REPLACED = 4001;
const CLOSE_AUTH_TIMEOUT = 4002;
const CLOSE_UNKNOWN_ROLE = 4003;
/** Geometry is presentation. No road is worth a quarter of a megabyte of it. */
const MAX_PATH_BYTES = 256 * 1024;
/** The codes a driver may give for not answering. Anything else is transient. */
const DRIVER_ERROR_CODES = new Set(["no-route", "unavailable", "invalid-input"]);
/**
* A driver said, in so many words, that it could not answer.
*
* Distinct from every other rejection on this path, which are all forms of
* silence: a timeout, a dropped socket, no driver at all. Silence says nothing
* about the road. This says something.
*/
class DriverError extends Error {
constructor(code, message) {
super(message || code);
this.name = "DriverError";
this.code = code;
}
}
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url, `http://${request.headers.host}`);
if (url.pathname === "/distance-driver") {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
} else {
// Let Socket.IO handle its own upgrades
// Do nothing here - Socket.IO hooks into the server internally
}
});
/** The open socket in a project's role slot, or null. */
function driverFor(projectId, role) {
const socket = drivers[projectId] && drivers[projectId][role];
return socket && socket.readyState === WebSocket.OPEN ? socket : null;
}
/**
* Puts a driver in its project's slot, evicting whatever was there.
*
* The old socket is closed rather than forgotten. A duplicate driver started
* by mistake - a second copy of a service, a stale container - would otherwise
* sit there connected, authenticated, and never asked for anything, which
* looks exactly like working. Closing it makes the mistake arrive at the
* machine that made it.
*/
function registerDriver(projectId, role, ws) {
const slot = drivers[projectId] || (drivers[projectId] = {});
const existing = slot[role];
if (existing && existing !== ws) {
console.warn(
`🔗 Evicting the ${role} driver on project ${projectId}: another one authenticated`,
);
failPending(existing, new Error("Distance driver was replaced"));
existing.close(CLOSE_REPLACED, "replaced by a newer driver");
}
slot[role] = ws;
}
/** Takes a driver out of its slot, if it is still the one in it. */
function unregisterDriver(ws) {
const slot = drivers[ws.projectId];
if (!slot || slot[ws.driverRole] !== ws) return false;
delete slot[ws.driverRole];
if (Object.keys(slot).length === 0) delete drivers[ws.projectId];
return true;
}
/**
* Fails every request this socket still owes an answer for.
*
* A dead socket is never going to answer, and leaving its requests on the
* thirty-second timer means an edge stays unresolved for half a minute after
* the reason it could not be resolved is already known.
*/
function failPending(ws, error) {
for (const [requestId, pending] of Object.entries(pendingDistanceRequests)) {
if (pending.ws !== ws) continue;
clearTimeout(pending.timeout);
delete pendingDistanceRequests[requestId];
pending.reject(error);
}
}
/**
* Claims the pending request a reply is for, or null.
*
* The socket has to match: request ids are handed to one driver, and a reply
* from a socket that was never asked is not an answer to anything.
*/
function takePending(requestId, ws) {
const pending = pendingDistanceRequests[requestId];
if (!pending || pending.ws !== ws) return null;
clearTimeout(pending.timeout);
delete pendingDistanceRequests[requestId];
return pending;
}
/** Absent means v1. Newer than this server means this server's version. */
function parseProtocolVersion(value) {
if (value === undefined || value === null) return 1;
const version = Number(value);
if (!Number.isInteger(version) || version < 1) return 1;
return Math.min(version, PROTOCOL_VERSION);
}
/** Absent means `distance`: v1 knew only one kind of driver. Null means unknown. */
function parseDriverRole(value) {
if (value === undefined || value === null) return DEFAULT_DRIVER_ROLE;
return DRIVER_ROLES.has(value) ? value : null;
}
/**
* Route geometry from a driver result, serialised and ready to store, or null.
*
* The server does not read route geometry. This checks the envelope and
* nothing inside it: that it is a LineString, that it is in the one format
* there is, and that it is small enough to keep. Anything that fails loses its
* geometry and keeps its distance - the distance is what enforcement runs on,
* and the shape of the road is what it looks like.
*/
function parsePath(path, pathFormat) {
if (path === undefined || path === null) return null;
if (typeof path !== "object" || path.type !== "LineString") {
console.warn("🔗 Dropping driver geometry: not a GeoJSON LineString");
return null;
}
if (
pathFormat !== undefined &&
pathFormat !== null &&
pathFormat !== PATH_FORMAT
) {
console.warn(`🔗 Dropping driver geometry: unknown format "${pathFormat}"`);
return null;
}
let serialised;
try {
serialised = JSON.stringify(path);
} catch {
console.warn("🔗 Dropping driver geometry: it does not serialise");
return null;
}
const bytes = Buffer.byteLength(serialised, "utf8");
if (bytes > MAX_PATH_BYTES) {
console.warn(
`🔗 Dropping driver geometry: ${bytes} bytes is over the ${MAX_PATH_BYTES} cap`,
);
return null;
}
return serialised;
}
/** How far each requested coordinate was from the road, serialised, or null. */
function parseEndpointOffsets(value) {
if (!Array.isArray(value)) return null;
if (!value.every((offset) => Number.isFinite(offset))) {
console.warn("🔗 Dropping driver endpointOffsets: not all finite numbers");
return null;
}
return JSON.stringify(value);
}
/**
* A map driver's announced UI address, normalised, or null.
*
* This decides what an operator will be asked to approve, and an approved
* address ends up in an iframe inside the console. Everything that is not an
* ordinary http(s) page is refused here rather than shown to somebody as a
* choice: `javascript:` and `data:` URLs execute in the console's own
* document if anything ever renders them into a link, credentials in a URL are
* a way of making one address look like another, and a fragment is not part of
* an origin and would only ever confuse the comparison below.
*/
const MAX_MAP_DRIVER_URL_LENGTH = 2048;
function parseMapDriverUrl(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > MAX_MAP_DRIVER_URL_LENGTH) return null;
let url;
try {
url = new URL(trimmed);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username || url.password) return null;
url.hash = "";
return { url: url.toString(), origin: url.origin };
}
/** Whether a map driver is in this project's map slot right now. */
function isMapDriverConnected(projectId) {
return driverFor(projectId, "map") !== null;
}
/**
* What the console needs to know about this project's map view.
*
* The stored state and the live socket are separate facts and both are here:
* an approved address whose driver is not running is a map view that will not
* load, and the console decides what to show from the pair.
*/
async function mapDriverState(projectId) {
const row = (await statements.getMapDriver(projectId, db)) || {};
return {
connected: isMapDriverConnected(projectId),
status: row.map_driver_status || MAP_DRIVER_STATUS.NONE,
url: row.map_driver_url || null,
origin: row.map_driver_origin || null,
pending_url: row.pending_map_driver_url || null,
};
}
/** Tells every console tab on this project where its map view stands. */
async function broadcastMapDriverState(projectId) {
try {
io.to(`project-${projectId}`).emit(
"map-driver-status",
await mapDriverState(projectId),
);
} catch (err) {
console.error("Error broadcasting map driver status:", err);
}
}
/**
* Files what a map driver said its UI address is.
*
* Announcing is not approving. The same address that is already approved
* changes nothing - a driver restarting on the same port must not send an
* operator back to the approval screen every time - and anything else becomes
* a proposal that will not be embedded until somebody agrees to it.
*/
async function recordAnnouncedMapDriverUrl(projectId, announced) {
const parsed = parseMapDriverUrl(announced);
if (announced !== undefined && announced !== null && !parsed) {
console.warn(
`🔗 Ignoring the UI address a map driver announced for project ${projectId}: ` +
"not an http(s) URL this server will offer for approval",
);
}
if (!parsed) return;
const current = (await statements.getMapDriver(projectId, db)) || {};
if (
current.map_driver_status === MAP_DRIVER_STATUS.APPROVED &&
current.map_driver_url === parsed.url
) {
console.log(
`🗺️ Map driver for project ${projectId} reconnected on its approved address`,
);
return;
}
if (current.pending_map_driver_url === parsed.url) return;
await statements.setPendingMapDriverUrl(projectId, parsed.url, db);
console.log(
`🗺️ Map driver for project ${projectId} announced ${parsed.url}; ` +
"waiting for an operator to approve it before the console embeds it",
);
}
/** Just enough of a capability set to read in a log line. */
function describeCapabilities(capabilities) {
const declared = Object.keys(capabilities).filter((key) => capabilities[key]);
return declared.length ? `, offering ${declared.join(" and ")}` : "";
}
wss.on("connection", (ws) => {
console.log("🔗 Driver WebSocket connected");
ws.isAuthenticated = false;
ws.projectId = null;
ws.driverRole = null;
ws.protocolVersion = 1;
ws.missedPongs = 0;
ws.authTimer = setTimeout(() => {
if (ws.isAuthenticated) return;
console.warn(
`🔗 Closing a driver socket that did not authenticate within ${AUTH_TIMEOUT_MS}ms`,
);
ws.close(CLOSE_AUTH_TIMEOUT, "authentication timeout");
}, AUTH_TIMEOUT_MS);
ws.on("pong", () => {
ws.missedPongs = 0;
});
ws.on("message", async (raw) => {
let msg;
try {
msg = JSON.parse(raw);
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return;
}
if (msg.type === "auth") {
const role = parseDriverRole(msg.role);
if (role === null) {
ws.send(
JSON.stringify({
type: "error",
message: `Unknown driver role "${msg.role}"`,
}),
);
ws.close(CLOSE_UNKNOWN_ROLE, "unknown driver role");
return;
}
let projectId;
try {
projectId = await APIKeyToProjectId(msg.apiKey, db);
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid API key" }));
return;
}
clearTimeout(ws.authTimer);
ws.isAuthenticated = true;
ws.projectId = projectId;
ws.driverRole = role;
ws.protocolVersion = parseProtocolVersion(msg.protocolVersion);
// Capabilities are additive and optional. Their absence costs the
// console a thing to draw; it never costs a connection, and nothing here
// reads them to decide what to send. If one ever had to be agreed on, it
// would belong in the invariants rather than in a handshake.
ws.capabilities =
msg.capabilities && typeof msg.capabilities === "object"
? msg.capabilities
: {};
registerDriver(projectId, role, ws);
ws.send(
JSON.stringify({
type: "authenticated",
projectId,
protocolVersion: ws.protocolVersion,
}),
);
console.log(
`🔗 ${role} driver "${msg.driverName || "unnamed"}" authenticated for ` +
`project ${projectId} on protocol v${ws.protocolVersion}` +
describeCapabilities(ws.capabilities),
);
if (role === "distance") {
io.to(`project-${projectId}`).emit("distance-driver-status", {
connected: true,
});
// Recalculate all edge distances now that a driver is available
recalculateAllDistances(projectId);
}
if (role === "map") {
try {
await recordAnnouncedMapDriverUrl(projectId, msg.uiUrl);
} catch (err) {
console.error("Error recording an announced map driver URL:", err);
}
void broadcastMapDriverState(projectId);
}
} else if (msg.type === "distance-result") {
const pending = takePending(msg.requestId, ws);
if (!pending) return;
pending.resolve({
distance: msg.distance,
path: parsePath(msg.path, msg.pathFormat),
endpointOffsets: parseEndpointOffsets(msg.endpointOffsets),
});
} else if (msg.type === "distance-error") {
const pending = takePending(msg.requestId, ws);
if (!pending) return;
const code = DRIVER_ERROR_CODES.has(msg.code) ? msg.code : "unavailable";
if (code === "invalid-input") {
// The driver is saying the server sent it something it could not use.
// That is a fault on this side of the wire and it should be loud.
console.error(
`🔗 Driver on project ${ws.projectId} rejected a request as invalid ` +
`input: ${msg.message || "(no message given)"}`,
);
}
pending.reject(new DriverError(code, msg.message));
}
});
ws.on("close", (code) => {
clearTimeout(ws.authTimer);
failPending(ws, new Error("Distance driver disconnected"));
if (!unregisterDriver(ws)) return;
console.log(
`🔗 ${ws.driverRole} driver disconnected from project ${ws.projectId} (code ${code})`,
);
if (ws.driverRole === "distance") {
io.to(`project-${ws.projectId}`).emit("distance-driver-status", {
connected: false,
});
}
// The approved address is still approved; there is just nothing serving
// it. The console falls back to the graph view on this.
if (ws.driverRole === "map") void broadcastMapDriverState(ws.projectId);
});
});
// A crashed driver's socket looks open until TCP works out that it is not, and
// while it does the project's slot is taken by something that will never
// answer. Two unanswered pings is the difference between a driver that is busy
// and one that is gone.
setInterval(() => {
for (const ws of wss.clients) {
if (ws.missedPongs >= MAX_MISSED_PONGS) {
console.warn(
`🔗 Terminating an unresponsive ${ws.driverRole || "unauthenticated"} ` +
`driver socket after ${ws.missedPongs} missed pings`,
);
ws.terminate();
continue;
}
ws.missedPongs = (ws.missedPongs || 0) + 1;
ws.ping();
}
}, HEARTBEAT_INTERVAL_MS);
/**
* The fingerprint of the two positions a distance was measured between.
*
* What makes a stored distance stale is one of its checkpoints moving, and the
* only way to notice that after the fact is to have written down where they
* were. Null when either position is unusable, which reads as "moved" - an
* edge whose ends cannot be identified is one worth asking about again.
*/
function endpointsHash(fromNode, toNode) {
const parts = [
fromNode.latitude,
fromNode.longitude,
toNode.latitude,
toNode.longitude,
].map(Number);
if (!parts.every(Number.isFinite)) return null;
return crypto.createHash("sha256").update(parts.join("|")).digest("hex");
}
/**
* Why this edge needs asking about again, or null if it does not.
*
* Three reasons, and nothing else counts: it has no usable distance; one of
* its checkpoints has moved since the distance was worked out; or it has no
* route geometry and the attached driver is one that produces geometry. An
* edge that fails all three is answered, current, and complete, and asking
* again would cost a routing request to be told what is already stored.
*/
function stalenessReason(connection, nodesById, driverHasGeometry) {
const fromNode = nodesById.get(connection.from_node_id);
const toNode = nodesById.get(connection.to_node_id);
// An edge whose endpoints are gone is not something a driver can answer.
if (!fromNode || !toNode) return null;
if (connection.distance_status !== DISTANCE_STATUS.OK) return "unresolved";
const hash = endpointsHash(fromNode, toNode);
// A NULL stored hash is every edge written before this was recorded. It
// reads as moved, so each one is recalculated once, and that pass is what
// gives it a hash.
if (hash === null || hash !== connection.endpoints_hash) {
return "endpoints moved";
}
if (driverHasGeometry && !connection.has_path) return "no geometry yet";
return null;
}
/**
* Asks the project's distance driver about the edges that have changed.
*
* Called whenever a distance driver connects, which used to mean every edge in
* the project at once, every time. That was tolerable while the answer was a
* single number; with geometry riding along it is wasteful, and against a
* shared routing service it is close to abusive. A driver restarting on a
* project whose distances are all current now sends nothing at all.
*/
async function recalculateAllDistances(projectId) {
try {
const driver = driverFor(projectId, "distance");
if (!driver) return;
// Declared capabilities decide what is worth asking for, never what is
// sent: a driver that does not produce geometry is simply not asked to
// fill geometry in. Nothing branches on which driver it is.
const driverHasGeometry = Boolean(
driver.capabilities && driver.capabilities.geometry,
);
const [connections, nodes] = await Promise.all([
statements.getConnectionsForRecalculation(projectId, db),
statements.getProjectNodes(projectId, db),
]);
const nodesById = new Map(nodes.map((node) => [node.node_id, node]));
const stale = [];
const reasons = {};
for (const conn of connections) {
const reason = stalenessReason(conn, nodesById, driverHasGeometry);
if (!reason) continue;
stale.push(conn);
reasons[reason] = (reasons[reason] || 0) + 1;
}
if (stale.length === 0) {
console.log(
`📏 Project ${projectId}: ${connections.length} edges considered, ` +
"0 stale, nothing to ask",
);
return;
}
console.log(
`📏 Project ${projectId}: ${connections.length} edges considered, ` +
`${stale.length} stale (` +
Object.entries(reasons)
.map(([reason, count]) => `${count} ${reason}`)
.join(", ") +
")",
);
const results = await Promise.allSettled(
stale.map(async (conn) => {
const fromNode = nodesById.get(conn.from_node_id);
const toNode = nodesById.get(conn.to_node_id);
const hash = endpointsHash(fromNode, toNode);
let answer;
try {
answer = await requestDistanceFromDriver(projectId, fromNode, toNode);
} catch (err) {
// A driver that states a reason is believed, and the edge is marked
// with what it said. Silence - a timeout, a dropped socket - is not
// a statement about the road, so the edge keeps whatever it had.
const stated = statusFromDriverError(err);
if (!stated) throw err;
await statements.updateConnection(
conn.connection_id,
projectId,
{
distance: null,
distanceStatus: stated,
speedLimit: conn.speed_limit,
path: null,
endpointOffsets: null,
endpointsHash: hash,
},
db,
);
io.to(`project-${projectId}`).emit("connection-updated", {
connection_id: conn.connection_id,
distance: null,
speed_limit: conn.speed_limit,
distance_status: stated,
});
console.warn(
`📏 Connection ${conn.connection_id} is ${stated}: driver said ${err.code} (${err.message})`,
);
return true;
}
const distance = parseNumeric(answer.distance);
// An unusable answer is not an answer. The stored figure is left as it
// was - a good distance is not downgraded because one reply was junk,
// and an unresolved edge stays unresolved rather than becoming a
// number that enforces nothing.
if (!isValidDistance(distance)) {
console.error(
`📏 Connection ${conn.connection_id}: driver answered with a non-distance`,
);
return false;
}
await statements.updateConnection(
conn.connection_id,
projectId,
{
distance,
distanceStatus: DISTANCE_STATUS.OK,
speedLimit: conn.speed_limit,
path: answer.path,
endpointOffsets: answer.endpointOffsets,
endpointsHash: hash,
},
db,
);
io.to(`project-${projectId}`).emit("connection-updated", {
connection_id: conn.connection_id,
distance,
speed_limit: conn.speed_limit,
distance_status: DISTANCE_STATUS.OK,
});
console.log(
`📏 Updated connection ${conn.connection_id}: distance=${distance}` +
(answer.path ? " with geometry" : ""),
);
return true;
}),
);
// A failed request leaves that edge exactly as it was. A driver blip must
// not turn a working road's distance into an unresolved one, and the edge
// keeps its old fingerprint, so the next driver to connect asks again.
const failed = results.filter((r) => r.status === "rejected").length;
const recalculated = results.filter(
(r) => r.status === "fulfilled" && r.value === true,
).length;
console.log(
`📏 Project ${projectId}: recalculated ${recalculated} of ${stale.length}` +
(failed ? `, ${failed} unanswered and unchanged` : ""),
);
} catch (err) {
console.error(`📏 Error recalculating distances: ${err.message}`);
}
}
function isDistanceDriverConnected(projectId) {
return driverFor(projectId, "distance") !== null;
}
/**
* The distance status a driver's stated failure implies, or null if it stated
* none.
*
* Silence is not a statement. A request that times out, or dies with its
* socket, leaves the edge as it was: nobody has said anything about that road,
* and a distance that was right an hour ago is still the best thing known. A
* `distance-error` is different - the driver looked, and is saying what it
* found. `no-route` is definitive and there is no point retrying it;
* `unavailable` and `invalid-input` are not, and the next reconnect will.
*/
function statusFromDriverError(err) {
if (!(err instanceof DriverError)) return null;
return err.code === "no-route"
? DISTANCE_STATUS.NO_ROUTE
: DISTANCE_STATUS.UNKNOWN;
}
/**
* Asks the project's distance driver how far it is from one node to another.
*
* Resolves to `{ distance, path, endpointOffsets }`; rejects with a
* DriverError when the driver said why it could not answer, and with a plain
* Error when it said nothing at all.
*
* Both the node indices and the coordinates go out every time. A v1 driver
* reads the indices and looks the positions up over REST; a v2 driver reads
* the coordinates and never asks. Nothing here needs to know which is on the
* other end, which is the point: one message serves both.
*/
function requestDistanceFromDriver(projectId, fromNode, toNode) {
return new Promise((resolve, reject) => {
const ws = driverFor(projectId, "distance");
if (!ws) {
reject(new Error("No distance driver connected"));
return;
}
const requestId = crypto.randomUUID();
const timeout = setTimeout(() => {
delete pendingDistanceRequests[requestId];
reject(new Error("Distance calculation timed out"));
}, DISTANCE_TIMEOUT_MS);
pendingDistanceRequests[requestId] = { resolve, reject, timeout, ws };
ws.send(
JSON.stringify({
type: "calculate-distance",
requestId,
fromIdInProject: fromNode.id_in_project,
toIdInProject: toNode.id_in_project,
from: { latitude: fromNode.latitude, longitude: fromNode.longitude },
to: { latitude: toNode.latitude, longitude: toNode.longitude },
}),
);
});
}
const db = createDatabase(path.join(__dirname, "database.db"));
app.use(express.json());
// --- The operator console ---
//
// The console is the Next.js app in `console/`, built to static files. Express
// serves that build where Next.js leaves it, so there is no copy step and no
// second directory to keep in sync. It is the same stack and the same design
// system as smartcheckpoints.xyz, and it talks to this process over the REST
// API and the Socket.IO channel below; there is no second server involved.
const consoleDir = path.join(__dirname, "console", "out");
// `_next` holds every stylesheet and script. Without it the pages load bare,
// which looks like a broken console rather than an unbuilt one.
const consoleBuilt =
fs.existsSync(path.join(consoleDir, "index.html")) &&
fs.existsSync(path.join(consoleDir, "_next"));
const NOT_BUILT_MESSAGE =
"The Smart Checkpoints console has not been built.\n\n" +
"Run this in the server directory, then reload:\n\n" +
" npm run build\n\n" +
"The REST API and both realtime channels work without it; only these\n" +
"pages are missing.\n";
if (!consoleBuilt) {
console.warn(
[
"⚠️ The console has not been built, so its pages are unavailable.",
" The REST API and both realtime channels are running normally.",
" Run `npm run build` in this directory to build them.",
].join("\n"),
);
}
/**
* Serves one exported console page.
*
* Next.js writes `<route>/index.html`, and these routes are registered ahead
* of `express.static` so `/admin` serves the page directly; left to itself,
* static answers the slashless form with a 301 to `/admin/` and makes every
* page load a round trip longer.
*/
function sendConsolePage(route) {
return (req, res) => {
if (!consoleBuilt) {
return res.status(503).type("text/plain").send(NOT_BUILT_MESSAGE);
}
// A missing file must not reach the browser as an ENOENT stack trace with
// an absolute path in it. Say what the startup warning says.
res.sendFile(path.join(consoleDir, route, "index.html"), (err) => {
if (!err || res.headersSent) return;
console.warn(`⚠️ Could not serve /${route}: ${err.message}`);
res.status(503).type("text/plain").send(NOT_BUILT_MESSAGE);
});
};
}
// The root is served explicitly too, so an unbuilt console answers it with the
// message above rather than with Express's own 404.
app.get("/", sendConsolePage(""));
app.get("/project", sendConsolePage("project"));
app.get("/admin", sendConsolePage("admin"));
if (consoleBuilt) {
app.use(express.static(consoleDir));
}
// The documentation lives in one place for the whole ecosystem, and this is
// not it. The server used to carry a hand-maintained copy of the API reference
// that drifted from the published one; this redirect is what replaced it.
app.get("/documentation", (req, res) => {
res.redirect(308, DOCS_URL);
});
app.get("/documentation/*splat", (req, res) => {
res.redirect(308, DOCS_URL);
});
// --- Ownership and validation ---
//
// Node ids and connection ids are sequential integers from AUTOINCREMENT, so
// an id taken from a request says nothing about who is allowed to touch it.
// Every project-scoped path checks the row's project against the project the
// key opens, and the two write paths - REST and Socket.IO - share one
// implementation rather than each carrying its own copy of the rules.
/**
* Rejects when `:id` in the path is not the project the API key opens.
*
* Declared once and applied as middleware, so a project-scoped endpoint added
* later cannot quietly skip the check by forgetting to write it out.
*/
function requireOwnProject(req, res, next) {
const projectId = parseInt(req.params.id, 10);
if (!Number.isInteger(projectId) || req.projectId !== projectId) {
return res.status(403).json({ error: "API key does not match project" });
}
next();
}
/**
* Coerces a request-body value to a number, returning NaN for anything that is
* not really one.
*
* Same rule, and the same reason, as `parseCoordinate` in geo.js: `Number()`
* maps null, "", [] and false to 0, and 0 is a catastrophic speed limit rather
* than an obviously missing one.
*/
function parseNumeric(value) {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim() !== "") return Number(value);
return NaN;
}
/** km/h. Finite and above zero, or the violation maths is meaningless. */
function isValidSpeedLimit(value) {
return Number.isFinite(value) && value > 0;
}
/** Metres. Finite and above zero: see INVALID_DISTANCE. */
function isValidDistance(value) {
return Number.isFinite(value) && value > 0;
}
/**
* Whether this edge may decide anything.
*
* The one question both the violation path and the congestion loop ask, asked
* the same way in both places. It is a status check, not an arithmetic one:
* enforcement is skipped because the distance is unresolved, not because a
* placeholder happens to divide into a comparison that is false for every car.
*/
function canEnforce(connection) {
return connection.distance_status === DISTANCE_STATUS.OK;
}
/**
* Both endpoints of a proposed edge, but only if both are nodes in this
* project. Returns null otherwise, and does not say which of the two failed:
* "not yours" and "does not exist" are the same answer to a caller who should
* not be able to tell them apart.
*/
async function resolveEdgeEndpoints(projectId, fromNodeId, toNodeId) {
if (!Number.isFinite(fromNodeId) || !Number.isFinite(toNodeId)) return null;
const [fromNode, toNode] = await Promise.all([
statements.getNodeByNodeId(fromNodeId, db),
statements.getNodeByNodeId(toNodeId, db),
]);
if (!fromNode || !toNode) return null;
if (fromNode.project_id !== projectId || toNode.project_id !== projectId) {
return null;
}
return { fromNode, toNode };
}
/**
* Creates an edge in `projectId`, asking the distance driver for the road
* distance when one is attached and no distance was given.
*
* Resolves to `{ ok: true, connection }` or `{ ok: false, status, error }`.
* The `connection-added` broadcast happens here, so both callers emit the same
* event with the same fields.
*/
async function createConnectionForProject(projectId, input) {
const fromNodeId = parseNumeric(input.fromNodeId);
const toNodeId = parseNumeric(input.toNodeId);
const speedLimit = parseNumeric(input.speedLimit);
if (!isValidSpeedLimit(speedLimit)) {
return { ok: false, status: 400, error: INVALID_SPEED_LIMIT };
}
const given = input.distance;
let distance = null;
if (given !== undefined && given !== null && given !== "") {
distance = parseNumeric(given);
if (!isValidDistance(distance)) {
return { ok: false, status: 400, error: INVALID_DISTANCE };
}
}
const endpoints = await resolveEdgeEndpoints(projectId, fromNodeId, toNodeId);
if (!endpoints) {
return { ok: false, status: 400, error: INVALID_ENDPOINTS };
}
const { fromNode, toNode } = endpoints;