@volter/twin-googlemaps 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +96 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +23 -0
  5. package/dist/src/googlemaps-auth.d.ts +31 -0
  6. package/dist/src/googlemaps-auth.js +37 -0
  7. package/dist/src/googlemaps-budget.d.ts +61 -0
  8. package/dist/src/googlemaps-budget.js +142 -0
  9. package/dist/src/googlemaps-capabilities.d.ts +3 -0
  10. package/dist/src/googlemaps-capabilities.js +648 -0
  11. package/dist/src/googlemaps-conformance.d.ts +8 -0
  12. package/dist/src/googlemaps-conformance.js +18 -0
  13. package/dist/src/googlemaps-connector.d.ts +94 -0
  14. package/dist/src/googlemaps-connector.js +243 -0
  15. package/dist/src/googlemaps-data.d.ts +87 -0
  16. package/dist/src/googlemaps-data.js +370 -0
  17. package/dist/src/googlemaps-perform-harness.d.ts +5 -0
  18. package/dist/src/googlemaps-perform-harness.js +17 -0
  19. package/dist/src/googlemaps-server.d.ts +14 -0
  20. package/dist/src/googlemaps-server.js +25 -0
  21. package/dist/src/googlemaps-twin.d.ts +2 -0
  22. package/dist/src/googlemaps-twin.js +1116 -0
  23. package/dist/src/googlemaps-types.d.ts +45 -0
  24. package/dist/src/googlemaps-types.js +1 -0
  25. package/dist/src/index.d.ts +11 -0
  26. package/dist/src/index.js +65 -0
  27. package/package.json +52 -0
  28. package/src/cli.ts +22 -0
  29. package/src/googlemaps-auth.ts +52 -0
  30. package/src/googlemaps-budget.ts +168 -0
  31. package/src/googlemaps-capabilities.ts +766 -0
  32. package/src/googlemaps-conformance.ts +24 -0
  33. package/src/googlemaps-connector.ts +250 -0
  34. package/src/googlemaps-data.ts +399 -0
  35. package/src/googlemaps-perform-harness.ts +17 -0
  36. package/src/googlemaps-server.ts +33 -0
  37. package/src/googlemaps-twin.ts +1110 -0
  38. package/src/googlemaps-types.ts +47 -0
  39. package/src/index.ts +124 -0
@@ -0,0 +1,1110 @@
1
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
2
+ import { authenticate, header } from './googlemaps-auth.ts';
3
+ import {
4
+ allPlaces,
5
+ countryOf,
6
+ decodePageToken,
7
+ deterministicDuration,
8
+ deterministicElevation,
9
+ deterministicLatLng,
10
+ deterministicTimeZone,
11
+ distanceText,
12
+ durationText,
13
+ encodePageToken,
14
+ encodePolyline,
15
+ findPlaceById,
16
+ findPlaceByLatLng,
17
+ findPlaceByText,
18
+ geocodeResultFromPlace,
19
+ haversineMeters,
20
+ isZeroResultsQuery,
21
+ localityOf,
22
+ localizeFormattedAddress,
23
+ normalizeAddress,
24
+ openingHoursFor,
25
+ plusCodeFor,
26
+ reviewsFor,
27
+ stableHash,
28
+ synthGeocodeResult,
29
+ viewportAround,
30
+ type CannedPlace,
31
+ } from './googlemaps-data.ts';
32
+ import type { GeocodeResult, GoogleMapsRequest, GoogleMapsResponse, LatLng, MapsStatus } from './googlemaps-types.ts';
33
+
34
+ const SERVICE = 'googlemaps';
35
+ const FIXED_NOW = '1970-01-01T00:00:00.000Z';
36
+
37
+ type Body = Record<string, any>;
38
+
39
+ /** Web-service status envelope (HTTP 200 carrying a `status` field). */
40
+ function maps(status: MapsStatus, extra: Body = {}, errorMessage?: string): GoogleMapsResponse {
41
+ return { status: 200, body: { status, ...(errorMessage ? { error_message: errorMessage } : {}), ...extra } };
42
+ }
43
+
44
+ /** google.rpc.Status envelope for the v1 / "New" APIs (real HTTP error codes). */
45
+ function rpc(httpStatus: number, code: number, statusText: string, message: string): GoogleMapsResponse {
46
+ return { status: httpStatus, body: { error: { code, message, status: statusText } } };
47
+ }
48
+
49
+ function parseBody(body?: string): Body {
50
+ if (!body?.trim()) return {};
51
+ try {
52
+ const parsed = JSON.parse(body);
53
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ function parseLatLng(input: string | null | undefined): LatLng | null {
60
+ if (!input) return null;
61
+ const m = input.trim().match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
62
+ if (!m) return null;
63
+ const lat = Number(m[1]);
64
+ const lng = Number(m[2]);
65
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
66
+ if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
67
+ return { lat, lng };
68
+ }
69
+
70
+ /** Resolve free text to a geocode result, honoring connector-seeded overrides. */
71
+ function resolveGeocode(address: string, root?: string): GeocodeResult | null {
72
+ if (isZeroResultsQuery(address)) return null;
73
+ const override = geocodeOverride(address, root);
74
+ if (override) return override;
75
+ const canned = findPlaceByText(address);
76
+ if (canned) return geocodeResultFromPlace(canned);
77
+ return synthGeocodeResult(address);
78
+ }
79
+
80
+ function geocodeOverride(address: string, root?: string): GeocodeResult | null {
81
+ const key = normalizeAddress(address);
82
+ const row = projectResources(SERVICE, root).find(
83
+ (r) => r.type === 'geocode' && (r as Body).normalized === key,
84
+ ) as Body | undefined;
85
+ return row ? (row.result as GeocodeResult) : null;
86
+ }
87
+
88
+ async function recordAudit(req: GoogleMapsRequest, entry: { api: string; status: string; query?: string }): Promise<void> {
89
+ if (req.readOnly) return;
90
+ try {
91
+ const seq = projectResources(SERVICE, req.root).filter((r) => r.type === 'audit').length;
92
+ const id = `audit_${stableHash({ ...entry, seq, at: req.occurredAt ?? FIXED_NOW })}`;
93
+ await applyTwinWrite(SERVICE, {
94
+ operation: 'audit.append',
95
+ subjectType: 'audit',
96
+ subjectId: id,
97
+ fields: { id, ...entry, occurredAt: req.occurredAt ?? FIXED_NOW },
98
+ occurredAt: req.occurredAt ?? FIXED_NOW,
99
+ actor: { kind: 'system' },
100
+ }, req.root);
101
+ } catch {
102
+ /* audit is advisory */
103
+ }
104
+ }
105
+
106
+ function auditRows(root?: string): Body[] {
107
+ return projectResources(SERVICE, root)
108
+ .filter((r) => r.type === 'audit')
109
+ .map((r) => { const { type: _t, updatedAt: _u, ...rest } = r; return rest as Body; });
110
+ }
111
+
112
+ // ---- per-API handlers ------------------------------------------------------
113
+
114
+ /** Parse a `components=country:GB|postal_code:94043` filter into a map. */
115
+ function parseComponents(raw: string | null): Record<string, string> {
116
+ const out: Record<string, string> = {};
117
+ if (!raw) return out;
118
+ for (const part of raw.split('|')) {
119
+ const [k, v] = part.split(':');
120
+ if (k && v) out[k.trim().toLowerCase()] = v.trim().toLowerCase();
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Attach a deterministic plus_code (global + compound) to a geocode result. */
126
+ function withPlusCode(result: GeocodeResult): GeocodeResult {
127
+ const components = Array.isArray(result.address_components) ? result.address_components : [];
128
+ const locality = components.find((c) => c.types?.includes('locality'))?.long_name
129
+ ?? components.find((c) => c.types?.includes('administrative_area_level_1'))?.long_name
130
+ ?? result.formatted_address;
131
+ return { ...result, plus_code: plusCodeFor(result.geometry.location, String(locality)) };
132
+ }
133
+
134
+ /** Parse a `bounds=lat,lng|lat,lng` viewport for biasing. */
135
+ function parseBounds(raw: string | null): { sw: LatLng; ne: LatLng } | null {
136
+ if (!raw) return null;
137
+ const [a, b] = raw.split('|');
138
+ const sw = parseLatLng(a);
139
+ const ne = parseLatLng(b);
140
+ return sw && ne ? { sw, ne } : null;
141
+ }
142
+
143
+ function handleGeocode(url: URL, root: string | undefined): GoogleMapsResponse {
144
+ const address = url.searchParams.get('address');
145
+ const componentsRaw = url.searchParams.get('components');
146
+ const place_id = url.searchParams.get('place_id');
147
+ const language = url.searchParams.get('language');
148
+ const bounds = parseBounds(url.searchParams.get('bounds'));
149
+ const components = parseComponents(componentsRaw);
150
+ if (!address && !componentsRaw && !place_id) {
151
+ return maps('INVALID_REQUEST', { results: [] }, 'Invalid request. Missing the `address`, `components`, or `place_id` parameter.');
152
+ }
153
+ if (place_id) {
154
+ const p = findPlaceById(place_id);
155
+ return p ? maps('OK', { results: [withPlusCode(localizeResult(geocodeResultFromPlace(p), language))] }) : maps('ZERO_RESULTS', { results: [] });
156
+ }
157
+ const query = address ?? componentsRaw!;
158
+ let result = resolveGeocode(query, root);
159
+ // Component filtering: a `country:` constraint must match the resolved result's
160
+ // country, otherwise ZERO_RESULTS — faithful to the real components filter.
161
+ if (result && components.country) {
162
+ const resolvedCountry = result.address_components.find((c) => c.types.includes('country'))?.short_name?.toLowerCase();
163
+ const matchedPlace = findPlaceByText(query);
164
+ const canned = matchedPlace ? countryOf(matchedPlace) : resolvedCountry;
165
+ if (canned && canned !== components.country) return maps('ZERO_RESULTS', { results: [] });
166
+ }
167
+ if (!result) return maps('ZERO_RESULTS', { results: [] });
168
+ // Bounds biasing: for a synthesized (non-canned) result, snap the location into
169
+ // the requested viewport so a `bounds` hint demonstrably steers the result.
170
+ if (bounds && !findPlaceByText(query)) {
171
+ const loc = result.geometry.location;
172
+ const biased: LatLng = {
173
+ lat: Math.round(Math.min(Math.max(loc.lat, bounds.sw.lat), bounds.ne.lat) * 1e7) / 1e7,
174
+ lng: Math.round(Math.min(Math.max(loc.lng, bounds.sw.lng), bounds.ne.lng) * 1e7) / 1e7,
175
+ };
176
+ result = { ...result, geometry: { ...result.geometry, location: biased, viewport: viewportAround(biased) } };
177
+ }
178
+ // Localized display names per the `language` parameter.
179
+ result = localizeResult(result, language);
180
+ // Plus Codes appear in every geocode result.
181
+ result = withPlusCode(result);
182
+ return maps('OK', { results: [result] });
183
+ }
184
+
185
+ /** Apply the `language` parameter to a geocode result's formatted_address. */
186
+ function localizeResult(result: GeocodeResult, language: string | null | undefined): GeocodeResult {
187
+ const localized = localizeFormattedAddress(result.formatted_address, language);
188
+ return localized === result.formatted_address ? result : { ...result, formatted_address: localized };
189
+ }
190
+
191
+ function handleReverseGeocode(url: URL): GoogleMapsResponse {
192
+ const latlng = parseLatLng(url.searchParams.get('latlng'));
193
+ if (!latlng) return maps('INVALID_REQUEST', { results: [] }, 'Invalid request. Missing or invalid `latlng` parameter.');
194
+ const p = findPlaceByLatLng(latlng);
195
+ const result: GeocodeResult = {
196
+ ...geocodeResultFromPlace(p, 'APPROXIMATE'),
197
+ geometry: { location: latlng, location_type: 'APPROXIMATE', viewport: viewportAround(latlng) },
198
+ };
199
+ return maps('OK', { results: [result] });
200
+ }
201
+
202
+ function handleDistanceMatrix(url: URL): GoogleMapsResponse {
203
+ const origins = (url.searchParams.get('origins') ?? '').split('|').filter(Boolean);
204
+ const destinations = (url.searchParams.get('destinations') ?? '').split('|').filter(Boolean);
205
+ if (origins.length === 0 || destinations.length === 0) {
206
+ return maps('INVALID_REQUEST', {}, 'Invalid request. Missing the `origins` or `destinations` parameter.');
207
+ }
208
+ // The real Distance Matrix caps a request at 100 elements (origins × destinations).
209
+ if (origins.length * destinations.length > 100) {
210
+ return maps('MAX_ELEMENTS_EXCEEDED', {}, 'Number of origins and destinations exceeds the per-query limit of 100 elements.');
211
+ }
212
+ const metric = url.searchParams.get('units') === 'metric';
213
+ const modeParam = (url.searchParams.get('mode') ?? 'driving').toLowerCase();
214
+ const mode = TRAVEL_MODES[modeParam] ?? 'DRIVING';
215
+ const speed = MODE_SPEED_KMH[mode] ?? 40;
216
+ const traffic = !!url.searchParams.get('departure_time') && mode === 'DRIVING';
217
+ const resolveLoc = (q: string): LatLng | null => parseLatLng(q) ?? (isZeroResultsQuery(q) ? null : (findPlaceByText(q)?.location ?? deterministicLatLng(normalizeAddress(q))));
218
+ const originLocs = origins.map(resolveLoc);
219
+ const destLocs = destinations.map(resolveLoc);
220
+ const origin_addresses = origins.map((o) => o);
221
+ const destination_addresses = destinations.map((d) => d);
222
+ const rows = originLocs.map((o) => ({
223
+ elements: destLocs.map((d) => {
224
+ if (!o || !d) return { status: 'NOT_FOUND' as const };
225
+ const meters = Math.max(1, haversineMeters(o, d));
226
+ const secs = Math.max(60, Math.round((meters / 1000 / speed) * 3600));
227
+ const el: Body = {
228
+ status: 'OK' as const,
229
+ duration: { value: secs, text: durationText(secs) },
230
+ distance: { value: meters, text: distanceText(meters, metric) },
231
+ };
232
+ if (traffic) {
233
+ const t = Math.round(secs * 1.25);
234
+ el.duration_in_traffic = { value: t, text: durationText(t) };
235
+ }
236
+ return el;
237
+ }),
238
+ }));
239
+ return maps('OK', { origin_addresses, destination_addresses, rows });
240
+ }
241
+
242
+ const TRAVEL_MODES: Record<string, string> = { driving: 'DRIVING', walking: 'WALKING', bicycling: 'BICYCLING', transit: 'TRANSIT' };
243
+ /** Average speed (km/h) per travel mode → deterministic duration scaling. */
244
+ const MODE_SPEED_KMH: Record<string, number> = { DRIVING: 40, WALKING: 5, BICYCLING: 15, TRANSIT: 30 };
245
+
246
+ function locOf(q: string): LatLng {
247
+ return parseLatLng(q) ?? findPlaceByText(q)?.location ?? deterministicLatLng(normalizeAddress(q));
248
+ }
249
+ function addrOf(q: string): string {
250
+ return parseLatLng(q) ? q : (findPlaceByText(q)?.formatted_address ?? q);
251
+ }
252
+
253
+ function buildLeg(oQ: string, dQ: string, mode: string, metric: boolean, opts: { traffic?: boolean; transit?: boolean } = {}): Body {
254
+ const oLoc = locOf(oQ);
255
+ const dLoc = locOf(dQ);
256
+ const meters = Math.max(1, haversineMeters(oLoc, dLoc));
257
+ const speed = MODE_SPEED_KMH[mode] ?? 40;
258
+ const secs = Math.max(60, Math.round((meters / 1000 / speed) * 3600));
259
+ const step: Body = {
260
+ travel_mode: mode,
261
+ distance: { value: meters, text: distanceText(meters, metric) },
262
+ duration: { value: secs, text: durationText(secs) },
263
+ start_location: oLoc,
264
+ end_location: dLoc,
265
+ html_instructions: `Head toward ${addrOf(dQ)}`,
266
+ polyline: { points: encodePolyline([oLoc, dLoc]) },
267
+ };
268
+ if (opts.transit && mode === 'TRANSIT') {
269
+ step.transit_details = {
270
+ arrival_stop: { name: `${addrOf(dQ)} Station`, location: dLoc },
271
+ departure_stop: { name: `${addrOf(oQ)} Station`, location: oLoc },
272
+ departure_time: { value: 0, text: '12:00am', time_zone: 'UTC' },
273
+ arrival_time: { value: secs, text: durationText(secs), time_zone: 'UTC' },
274
+ headsign: addrOf(dQ),
275
+ num_stops: Math.max(1, Math.round(meters / 2000)),
276
+ line: {
277
+ name: 'Twin Line',
278
+ short_name: 'TL',
279
+ vehicle: { name: 'Train', type: 'HEAVY_RAIL', icon: '//twin/rail.png' },
280
+ agencies: [{ name: 'Twin Transit', url: 'https://twin.local' }],
281
+ },
282
+ };
283
+ }
284
+ const leg: Body = {
285
+ distance: { value: meters, text: distanceText(meters, metric) },
286
+ duration: { value: secs, text: durationText(secs) },
287
+ start_address: addrOf(oQ),
288
+ end_address: addrOf(dQ),
289
+ start_location: oLoc,
290
+ end_location: dLoc,
291
+ steps: [step],
292
+ };
293
+ if (opts.traffic) {
294
+ // duration_in_traffic is modeled as ~25% slower than free-flow, deterministic.
295
+ const trafficSecs = Math.round(secs * 1.25);
296
+ leg.duration_in_traffic = { value: trafficSecs, text: durationText(trafficSecs) };
297
+ }
298
+ return leg;
299
+ }
300
+
301
+ function buildRoute(points: string[], mode: string, metric: boolean, waypoint_order: number[], opts: { traffic?: boolean; transit?: boolean; warnings?: string[]; summarySuffix?: string }): Body {
302
+ const legs: Body[] = [];
303
+ for (let i = 0; i < points.length - 1; i++) {
304
+ legs.push(buildLeg(points[i]!, points[i + 1]!, mode, metric, opts));
305
+ }
306
+ const allLocs = points.map(locOf);
307
+ const lats = allLocs.map((l) => l.lat);
308
+ const lngs = allLocs.map((l) => l.lng);
309
+ const center = { lat: (Math.min(...lats) + Math.max(...lats)) / 2, lng: (Math.min(...lngs) + Math.max(...lngs)) / 2 };
310
+ return {
311
+ summary: `${addrOf(points[0]!)} to ${addrOf(points[points.length - 1]!)}${opts.summarySuffix ?? ''}`,
312
+ legs,
313
+ overview_polyline: { points: encodePolyline(allLocs) },
314
+ bounds: viewportAround(center, (Math.max(...lats) - Math.min(...lats)) / 2 + 0.01),
315
+ copyrights: 'Map data ©2024 (twin)',
316
+ warnings: opts.warnings ?? [],
317
+ waypoint_order,
318
+ };
319
+ }
320
+
321
+ function handleDirections(url: URL): GoogleMapsResponse {
322
+ const origin = url.searchParams.get('origin');
323
+ const destination = url.searchParams.get('destination');
324
+ if (!origin || !destination) return maps('INVALID_REQUEST', { routes: [] }, 'Invalid request. Missing the `origin` or `destination` parameter.');
325
+ if (isZeroResultsQuery(origin) || isZeroResultsQuery(destination)) return maps('ZERO_RESULTS', { routes: [] });
326
+ const metric = url.searchParams.get('units') === 'metric';
327
+ const modeParam = (url.searchParams.get('mode') ?? 'driving').toLowerCase();
328
+ const mode = TRAVEL_MODES[modeParam] ?? 'DRIVING';
329
+
330
+ // Waypoints (optionally optimize:true → reordered waypoint_order).
331
+ const wpRaw = url.searchParams.get('waypoints');
332
+ let waypoints: string[] = [];
333
+ let optimize = false;
334
+ if (wpRaw) {
335
+ let spec = wpRaw;
336
+ if (/^optimize:true\|/i.test(spec)) { optimize = true; spec = spec.replace(/^optimize:true\|/i, ''); }
337
+ waypoints = spec.split('|').map((w) => w.replace(/^via:/i, '')).filter(Boolean);
338
+ }
339
+ // optimize reorders waypoints by nearest-neighbor from the origin (deterministic).
340
+ let waypoint_order: number[] = waypoints.map((_w, i) => i);
341
+ let orderedWaypoints = waypoints;
342
+ if (optimize && waypoints.length > 1) {
343
+ const remaining = waypoints.map((w, i) => ({ w, i }));
344
+ const ordered: { w: string; i: number }[] = [];
345
+ let cur = locOf(origin);
346
+ while (remaining.length) {
347
+ let bestIdx = 0;
348
+ let bestD = Number.POSITIVE_INFINITY;
349
+ for (let k = 0; k < remaining.length; k++) {
350
+ const d = haversineMeters(cur, locOf(remaining[k]!.w));
351
+ if (d < bestD) { bestD = d; bestIdx = k; }
352
+ }
353
+ const chosen = remaining.splice(bestIdx, 1)[0]!;
354
+ ordered.push(chosen);
355
+ cur = locOf(chosen.w);
356
+ }
357
+ orderedWaypoints = ordered.map((o) => o.w);
358
+ waypoint_order = ordered.map((o) => o.i);
359
+ }
360
+ const points = [origin, ...orderedWaypoints, destination];
361
+
362
+ const departureTime = url.searchParams.get('departure_time');
363
+ const traffic = !!departureTime && mode === 'DRIVING';
364
+ const transit = mode === 'TRANSIT';
365
+
366
+ // avoid=tolls|highways|ferries → recorded as route warnings.
367
+ const avoid = url.searchParams.get('avoid');
368
+ const warnings: string[] = [];
369
+ if (avoid) for (const a of avoid.split('|')) warnings.push(`Route avoids ${a}.`);
370
+
371
+ const routes: Body[] = [buildRoute(points, mode, metric, waypoint_order, { traffic, transit, warnings })];
372
+
373
+ // alternatives=true → an additional, slightly-longer route variant.
374
+ if (url.searchParams.get('alternatives') === 'true') {
375
+ const alt = buildRoute(points, mode, metric, waypoint_order, { traffic, transit, warnings, summarySuffix: ' (alt)' });
376
+ // Make the alternative deterministically longer so it is distinguishable.
377
+ for (const leg of alt.legs as Body[]) {
378
+ leg.duration = { value: Math.round(leg.duration.value * 1.15), text: durationText(Math.round(leg.duration.value * 1.15)) };
379
+ }
380
+ routes.push(alt);
381
+ }
382
+
383
+ const geocoded_waypoints = points.map((q) => ({
384
+ geocoder_status: 'OK',
385
+ place_id: findPlaceByText(q)?.place_id ?? `ChIJ${stableHash(q)}twin`,
386
+ types: ['street_address'],
387
+ }));
388
+ return maps('OK', { geocoded_waypoints, routes });
389
+ }
390
+
391
+ function placeToFindCandidate(p: CannedPlace): Body {
392
+ return {
393
+ place_id: p.place_id,
394
+ name: p.name,
395
+ formatted_address: p.formatted_address,
396
+ geometry: { location: p.location, viewport: viewportAround(p.location) },
397
+ types: p.types,
398
+ };
399
+ }
400
+
401
+ function handleFindPlace(url: URL): GoogleMapsResponse {
402
+ const input = url.searchParams.get('input');
403
+ if (!input) return maps('INVALID_REQUEST', { candidates: [] }, 'Missing the required `input` parameter.');
404
+ if (isZeroResultsQuery(input)) return maps('ZERO_RESULTS', { candidates: [] });
405
+ const p = findPlaceByText(input);
406
+ return maps('OK', { candidates: p ? [placeToFindCandidate(p)] : [placeToFindCandidate(allPlaces()[0]!)] });
407
+ }
408
+
409
+ /** A deterministic photo block (legacy Places) for a place. */
410
+ function legacyPhotos(p: CannedPlace): Body[] {
411
+ return [{
412
+ photo_reference: `twinphoto_${stableHash(p.place_id)}`,
413
+ height: 1080,
414
+ width: 1920,
415
+ html_attributions: [`<a href="https://maps.google.com/">${p.name}</a>`],
416
+ }];
417
+ }
418
+
419
+ /**
420
+ * The twin returns a deterministic, paginated list of nearby places for a query so
421
+ * next_page_token works faithfully. Build a stable candidate list around a seed.
422
+ */
423
+ function pagedCandidates(seed: string, anchor: CannedPlace): CannedPlace[] {
424
+ // First result is the anchor itself (so single-place lookups keep their canonical
425
+ // place_id); the remaining 22 are deterministic nearby variants for pagination.
426
+ const out: CannedPlace[] = [anchor];
427
+ for (let i = 1; i < 23; i++) {
428
+ out.push({
429
+ ...anchor,
430
+ place_id: `ChIJpage${stableHash(`${seed}:${i}`)}`,
431
+ name: `${anchor.name} POI ${i}`,
432
+ formatted_address: `${i} ${anchor.formatted_address}`,
433
+ });
434
+ }
435
+ return out;
436
+ }
437
+
438
+ function handleTextSearchLegacy(url: URL): GoogleMapsResponse {
439
+ const query = url.searchParams.get('query');
440
+ const pageToken = url.searchParams.get('pagetoken');
441
+ if (pageToken) {
442
+ const decoded = decodePageToken(pageToken);
443
+ if (!decoded) return maps('INVALID_REQUEST', { results: [] }, 'Invalid `pagetoken`.');
444
+ const anchor = findPlaceByText(decoded.query) ?? allPlaces()[0]!;
445
+ const all = pagedCandidates(decoded.query, anchor);
446
+ const page = all.slice(decoded.offset, decoded.offset + 20);
447
+ const nextOffset = decoded.offset + 20;
448
+ const results = page.map((p) => ({ ...placeToFindCandidate(p), rating: 4.4, user_ratings_total: 1280, business_status: 'OPERATIONAL' }));
449
+ const body: Body = { status: 'OK', results, html_attributions: [] };
450
+ if (nextOffset < all.length) body.next_page_token = encodePageToken(decoded.query, nextOffset);
451
+ return { status: 200, body };
452
+ }
453
+ if (!query) return maps('INVALID_REQUEST', { results: [] }, 'Missing the required `query` parameter.');
454
+ if (isZeroResultsQuery(query)) return maps('ZERO_RESULTS', { results: [] });
455
+ const anchor = findPlaceByText(query) ?? allPlaces()[0]!;
456
+ const all = pagedCandidates(query, anchor);
457
+ const page = all.slice(0, 20);
458
+ const results = page.map((p) => ({ ...placeToFindCandidate(p), rating: 4.4, user_ratings_total: 1280, business_status: 'OPERATIONAL' }));
459
+ return maps('OK', { results, next_page_token: encodePageToken(query, 20), html_attributions: [] });
460
+ }
461
+
462
+ function handleNearbySearch(url: URL): GoogleMapsResponse {
463
+ const pageToken = url.searchParams.get('pagetoken');
464
+ if (pageToken) {
465
+ const decoded = decodePageToken(pageToken);
466
+ if (!decoded) return maps('INVALID_REQUEST', { results: [] }, 'Invalid `pagetoken`.');
467
+ const anchor = findPlaceByText(decoded.query) ?? allPlaces()[0]!;
468
+ const all = pagedCandidates(decoded.query, anchor);
469
+ const page = all.slice(decoded.offset, decoded.offset + 20);
470
+ const nextOffset = decoded.offset + 20;
471
+ const results = page.map((p) => ({ ...placeToFindCandidate(p), rating: 4.2, user_ratings_total: 512, vicinity: p.formatted_address }));
472
+ const body: Body = { status: 'OK', results, html_attributions: [] };
473
+ if (nextOffset < all.length) body.next_page_token = encodePageToken(decoded.query, nextOffset);
474
+ return { status: 200, body };
475
+ }
476
+ const location = parseLatLng(url.searchParams.get('location'));
477
+ const radius = url.searchParams.get('radius');
478
+ const rankby = url.searchParams.get('rankby');
479
+ if (!location) return maps('INVALID_REQUEST', { results: [] }, 'Missing or invalid `location` parameter.');
480
+ if (!radius && rankby !== 'distance') return maps('INVALID_REQUEST', { results: [] }, 'You must specify a `radius` or set `rankby=distance`.');
481
+ const anchor = findPlaceByLatLng(location);
482
+ const seed = `${location.lat},${location.lng}`;
483
+ const all = pagedCandidates(seed, anchor);
484
+ const page = all.slice(0, 20);
485
+ const results = page.map((p) => ({ ...placeToFindCandidate(p), rating: 4.2, user_ratings_total: 512, vicinity: p.formatted_address }));
486
+ return maps('OK', { results, next_page_token: encodePageToken(seed, 20), html_attributions: [] });
487
+ }
488
+
489
+ function handlePlaceDetails(url: URL): GoogleMapsResponse {
490
+ const placeId = url.searchParams.get('place_id');
491
+ if (!placeId) return maps('INVALID_REQUEST', {}, 'Missing the required `place_id` parameter.');
492
+ const p = findPlaceById(placeId);
493
+ if (!p) return maps('NOT_FOUND', {}, 'The provided `place_id` was not found.');
494
+ const full: Body = {
495
+ place_id: p.place_id,
496
+ name: p.name,
497
+ formatted_address: p.formatted_address,
498
+ address_components: p.components,
499
+ geometry: { location: p.location, viewport: viewportAround(p.location) },
500
+ types: p.types,
501
+ url: `https://maps.google.com/?cid=${stableHash(p.place_id)}`,
502
+ utc_offset: 0,
503
+ rating: 4.4,
504
+ user_ratings_total: 1280,
505
+ business_status: 'OPERATIONAL',
506
+ opening_hours: openingHoursFor(p),
507
+ current_opening_hours: openingHoursFor(p),
508
+ reviews: reviewsFor(p),
509
+ editorial_summary: { overview: `${p.name} — a notable place.`, language: 'en' },
510
+ photos: legacyPhotos(p),
511
+ plus_code: plusCodeFor(p.location, localityOf(p)),
512
+ };
513
+ // `fields` parameter masks the response to only the requested top-level fields.
514
+ const fieldsParam = url.searchParams.get('fields');
515
+ let result: Body = full;
516
+ if (fieldsParam) {
517
+ const wanted = new Set(fieldsParam.split(',').map((f) => f.trim()).filter(Boolean));
518
+ result = {};
519
+ for (const key of Object.keys(full)) if (wanted.has(key)) result[key] = full[key];
520
+ }
521
+ return maps('OK', { result, html_attributions: [] });
522
+ }
523
+
524
+ function handlePlacePhoto(url: URL): GoogleMapsResponse {
525
+ const ref = url.searchParams.get('photo_reference') ?? url.searchParams.get('photoreference');
526
+ const maxwidth = url.searchParams.get('maxwidth');
527
+ const maxheight = url.searchParams.get('maxheight');
528
+ if (!ref) return maps('INVALID_REQUEST', {}, 'Missing the required `photo_reference` parameter.');
529
+ if (!maxwidth && !maxheight) return maps('INVALID_REQUEST', {}, 'You must specify `maxwidth` or `maxheight`.');
530
+ // The real endpoint 302-redirects to image bytes on the CDN; the twin returns the
531
+ // metadata of that redirect (serving deterministic bytes is a filed todo).
532
+ return {
533
+ status: 200,
534
+ body: {
535
+ contentType: 'image/jpeg',
536
+ photo_reference: ref,
537
+ maxwidth: maxwidth ? Number(maxwidth) : undefined,
538
+ maxheight: maxheight ? Number(maxheight) : undefined,
539
+ twinNote: 'twin returns photo metadata, not real imagery pixels',
540
+ },
541
+ };
542
+ }
543
+
544
+ function handleAutocomplete(url: URL): GoogleMapsResponse {
545
+ const input = url.searchParams.get('input');
546
+ if (!input) return maps('INVALID_REQUEST', { predictions: [] }, 'Missing the required `input` parameter.');
547
+ if (isZeroResultsQuery(input)) return maps('ZERO_RESULTS', { predictions: [] });
548
+ const n = normalizeAddress(input);
549
+ const matches = allPlaces().filter((p) => normalizeAddress(p.name).startsWith(n) || normalizeAddress(p.name).includes(n));
550
+ const list = (matches.length ? matches : [allPlaces()[0]!]).slice(0, 5);
551
+ const predictions = list.map((p) => ({
552
+ description: p.formatted_address,
553
+ place_id: p.place_id,
554
+ structured_formatting: { main_text: p.name, secondary_text: p.formatted_address },
555
+ types: p.types,
556
+ terms: [{ offset: 0, value: p.name }],
557
+ matched_substrings: [{ length: input.length, offset: 0 }],
558
+ }));
559
+ return maps('OK', { predictions });
560
+ }
561
+
562
+ function handleQueryAutocomplete(url: URL): GoogleMapsResponse {
563
+ const input = url.searchParams.get('input');
564
+ if (!input) return maps('INVALID_REQUEST', { predictions: [] }, 'Missing the required `input` parameter.');
565
+ const p = findPlaceByText(input) ?? allPlaces()[0]!;
566
+ return maps('OK', { predictions: [{ description: `${input} near ${p.name}`, place_id: p.place_id, types: ['establishment'] }] });
567
+ }
568
+
569
+ // ---- Places API (New) — v1, header-key, google.rpc.Status errors -----------
570
+
571
+ function newPlace(p: CannedPlace): Body {
572
+ return {
573
+ id: p.place_id,
574
+ name: `places/${p.place_id}`,
575
+ displayName: { text: p.name, languageCode: 'en' },
576
+ formattedAddress: p.formatted_address,
577
+ location: { latitude: p.location.lat, longitude: p.location.lng },
578
+ types: p.types,
579
+ rating: 4.4,
580
+ userRatingCount: 1280,
581
+ businessStatus: 'OPERATIONAL',
582
+ googleMapsUri: `https://maps.google.com/?cid=${stableHash(p.place_id)}`,
583
+ photos: [{
584
+ name: `places/${p.place_id}/photos/twinphoto_${stableHash(p.place_id)}`,
585
+ widthPx: 1920,
586
+ heightPx: 1080,
587
+ authorAttributions: [{ displayName: p.name, uri: 'https://maps.google.com/' }],
588
+ }],
589
+ };
590
+ }
591
+
592
+ /**
593
+ * Apply an X-Goog-FieldMask to a response object. A mask like
594
+ * `places.displayName,places.location` retains only those dotted paths; `*`
595
+ * returns everything. Missing mask on a v1 read is itself an INVALID_ARGUMENT in
596
+ * the real API for the GET-details path, but for searches an empty mask returns
597
+ * the full object here (callers that care assert specific retained fields).
598
+ */
599
+ /** The Places API always returns the resource `name` and `id` regardless of mask. */
600
+ function preserveIdentifiers(target: Body, source: Body): void {
601
+ if (typeof source.name === 'string') target.name = source.name;
602
+ if (typeof source.id === 'string') target.id = source.id;
603
+ }
604
+
605
+ function applyFieldMask<T extends Body>(obj: T, mask: string | undefined): Body {
606
+ if (!mask || mask.trim() === '*' || !mask.trim()) return obj;
607
+ const paths = mask.split(',').map((p) => p.trim()).filter(Boolean);
608
+ const out: Body = {};
609
+ const setPath = (target: Body, source: Body, parts: string[]): void => {
610
+ const [head, ...rest] = parts;
611
+ if (head === undefined) return;
612
+ if (source[head] === undefined) return;
613
+ if (rest.length === 0) { target[head] = source[head]; return; }
614
+ if (Array.isArray(source[head])) {
615
+ target[head] = (source[head] as Body[]).map((item, i) => {
616
+ const sub: Body = (target[head] as Body[] | undefined)?.[i] ?? {};
617
+ setPath(sub, item, rest);
618
+ preserveIdentifiers(sub, item);
619
+ return sub;
620
+ });
621
+ return;
622
+ }
623
+ target[head] = target[head] ?? {};
624
+ setPath(target[head] as Body, source[head] as Body, rest);
625
+ };
626
+ for (const path of paths) setPath(out, obj, path.split('.'));
627
+ // Top-level identifiers (details endpoint) are always present.
628
+ preserveIdentifiers(out, obj);
629
+ return out;
630
+ }
631
+
632
+ function pagedNewPlaces(seed: string, anchor: CannedPlace): CannedPlace[] {
633
+ return pagedCandidates(seed, anchor);
634
+ }
635
+
636
+ function handleNewTextSearch(body: Body, mask: string | undefined): GoogleMapsResponse {
637
+ const q = typeof body.textQuery === 'string' ? body.textQuery : '';
638
+ const pageToken = typeof body.pageToken === 'string' ? body.pageToken : undefined;
639
+ if (pageToken) {
640
+ const decoded = decodePageToken(pageToken);
641
+ if (!decoded) return rpc(400, 400, 'INVALID_ARGUMENT', 'Invalid pageToken.');
642
+ const anchor = findPlaceByText(decoded.query) ?? allPlaces()[0]!;
643
+ const all = pagedNewPlaces(decoded.query, anchor);
644
+ const page = all.slice(decoded.offset, decoded.offset + 20).map(newPlace);
645
+ const out: Body = { places: page };
646
+ if (decoded.offset + 20 < all.length) out.nextPageToken = encodePageToken(decoded.query, decoded.offset + 20);
647
+ return { status: 200, body: applyFieldMask(out, mask) };
648
+ }
649
+ if (!q.trim()) return rpc(400, 400, 'INVALID_ARGUMENT', 'textQuery must be non-empty.');
650
+ if (isZeroResultsQuery(q)) return { status: 200, body: {} };
651
+ // pageSize controls page length; default 20 keeps a single place for simple queries.
652
+ const pageSize = typeof body.pageSize === 'number' ? body.pageSize : undefined;
653
+ if (pageSize !== undefined) {
654
+ const anchor = findPlaceByText(q) ?? allPlaces()[0]!;
655
+ const all = pagedNewPlaces(q, anchor);
656
+ const page = all.slice(0, pageSize).map(newPlace);
657
+ const out: Body = { places: page };
658
+ if (pageSize < all.length) out.nextPageToken = encodePageToken(q, pageSize);
659
+ return { status: 200, body: applyFieldMask(out, mask) };
660
+ }
661
+ const p = findPlaceByText(q) ?? allPlaces()[0]!;
662
+ return { status: 200, body: applyFieldMask({ places: [newPlace(p)] }, mask) };
663
+ }
664
+
665
+ function handleNewNearbySearch(body: Body, mask: string | undefined): GoogleMapsResponse {
666
+ const center = body.locationRestriction?.circle?.center;
667
+ if (!center || typeof center.latitude !== 'number' || typeof center.longitude !== 'number') {
668
+ return rpc(400, 400, 'INVALID_ARGUMENT', 'locationRestriction.circle.center is required.');
669
+ }
670
+ const p = findPlaceByLatLng({ lat: center.latitude, lng: center.longitude });
671
+ return { status: 200, body: applyFieldMask({ places: [newPlace(p)] }, mask) };
672
+ }
673
+
674
+ function handleNewAutocomplete(body: Body): GoogleMapsResponse {
675
+ const input = typeof body.input === 'string' ? body.input : '';
676
+ if (!input.trim()) return rpc(400, 400, 'INVALID_ARGUMENT', 'input must be non-empty.');
677
+ const n = normalizeAddress(input);
678
+ const matches = allPlaces().filter((p) => normalizeAddress(p.name).includes(n));
679
+ const list = (matches.length ? matches : [allPlaces()[0]!]).slice(0, 5);
680
+ const suggestions = list.map((p) => ({
681
+ placePrediction: {
682
+ place: `places/${p.place_id}`,
683
+ placeId: p.place_id,
684
+ text: { text: p.formatted_address },
685
+ structuredFormat: { mainText: { text: p.name }, secondaryText: { text: p.formatted_address } },
686
+ types: p.types,
687
+ },
688
+ }));
689
+ return { status: 200, body: { suggestions } };
690
+ }
691
+
692
+ function handleNewPlaceDetails(placeId: string, mask: string | undefined): GoogleMapsResponse {
693
+ const p = findPlaceById(placeId);
694
+ if (!p) return rpc(404, 404, 'NOT_FOUND', `Place not found: places/${placeId}`);
695
+ return { status: 200, body: applyFieldMask(newPlace(p), mask) };
696
+ }
697
+
698
+ /**
699
+ * Places API (New) Place Photo media endpoint:
700
+ * GET /v1/places/{place_id}/photos/{photo_resource}/media?maxWidthPx=...
701
+ * Real API returns a photoUri (or binary). The twin returns photo metadata.
702
+ */
703
+ function handleNewPlacePhotoMedia(url: URL, placeId: string, photoRef: string): GoogleMapsResponse {
704
+ const maxWidthPx = url.searchParams.get('maxWidthPx');
705
+ const maxHeightPx = url.searchParams.get('maxHeightPx');
706
+ if (!maxWidthPx && !maxHeightPx) return rpc(400, 400, 'INVALID_ARGUMENT', 'maxWidthPx or maxHeightPx is required.');
707
+ const p = findPlaceById(placeId);
708
+ if (!p) return rpc(404, 404, 'NOT_FOUND', `Place not found: places/${placeId}`);
709
+ return {
710
+ status: 200,
711
+ body: {
712
+ name: `places/${placeId}/photos/${photoRef}/media`,
713
+ photoUri: `https://twin.local/places/${placeId}/photos/${photoRef}=s${maxWidthPx ?? maxHeightPx}`,
714
+ contentType: 'image/jpeg',
715
+ twinNote: 'twin returns photo metadata, not real imagery pixels',
716
+ },
717
+ };
718
+ }
719
+
720
+ // ---- Roads -----------------------------------------------------------------
721
+
722
+ function snapPoint(loc: LatLng, originalIndex: number | undefined): Body {
723
+ return {
724
+ location: { latitude: loc.lat, longitude: loc.lng },
725
+ placeId: `ChIJroad${stableHash(`${loc.lat},${loc.lng}`)}`,
726
+ ...(originalIndex !== undefined ? { originalIndex } : {}),
727
+ };
728
+ }
729
+
730
+ function handleSnapToRoads(url: URL): GoogleMapsResponse {
731
+ const path = url.searchParams.get('path');
732
+ if (!path) return rpc(400, 400, 'INVALID_ARGUMENT', 'Request must specify a path.');
733
+ const pts = path.split('|').map(parseLatLng);
734
+ if (pts.some((p) => p === null)) return rpc(400, 400, 'INVALID_ARGUMENT', 'Invalid lat/lng in path.');
735
+ const inputs = pts as LatLng[];
736
+ const interpolate = url.searchParams.get('interpolate') === 'true';
737
+ if (interpolate && inputs.length >= 2) {
738
+ // Densify: between each pair of input points, insert one interpolated point.
739
+ // Original points keep their originalIndex; interpolated points omit it.
740
+ const snappedPoints: Body[] = [];
741
+ for (let i = 0; i < inputs.length; i++) {
742
+ snappedPoints.push(snapPoint(inputs[i]!, i));
743
+ if (i < inputs.length - 1) {
744
+ const mid: LatLng = {
745
+ lat: Math.round(((inputs[i]!.lat + inputs[i + 1]!.lat) / 2) * 1e7) / 1e7,
746
+ lng: Math.round(((inputs[i]!.lng + inputs[i + 1]!.lng) / 2) * 1e7) / 1e7,
747
+ };
748
+ snappedPoints.push(snapPoint(mid, undefined));
749
+ }
750
+ }
751
+ return { status: 200, body: { snappedPoints } };
752
+ }
753
+ const snappedPoints = inputs.map((p, i) => snapPoint(p, i));
754
+ return { status: 200, body: { snappedPoints } };
755
+ }
756
+
757
+ function handleNearestRoads(url: URL): GoogleMapsResponse {
758
+ const points = url.searchParams.get('points');
759
+ if (!points) return rpc(400, 400, 'INVALID_ARGUMENT', 'Request must specify points.');
760
+ const pts = points.split('|').map(parseLatLng);
761
+ if (pts.some((p) => p === null)) return rpc(400, 400, 'INVALID_ARGUMENT', 'Invalid lat/lng in points.');
762
+ const snappedPoints = (pts as LatLng[]).map((p, i) => snapPoint(p, i));
763
+ return { status: 200, body: { snappedPoints } };
764
+ }
765
+
766
+ function handleSpeedLimits(url: URL): GoogleMapsResponse {
767
+ const path = url.searchParams.get('path');
768
+ const placeIds = url.searchParams.getAll('placeId');
769
+ if (!path && placeIds.length === 0) return rpc(400, 400, 'INVALID_ARGUMENT', 'Request must specify path or placeId.');
770
+ const ids = placeIds.length ? placeIds : (path!.split('|').map(parseLatLng).filter((p): p is LatLng => !!p).map((p) => `ChIJroad${stableHash(`${p.lat},${p.lng}`)}`));
771
+ const speedLimits = ids.map((placeId) => ({ placeId, speedLimit: 45 + (parseInt(stableHash(placeId).slice(0, 2), 16) % 40), units: 'KPH' }));
772
+ return { status: 200, body: { speedLimits } };
773
+ }
774
+
775
+ // ---- Time Zone / Elevation / Geolocation -----------------------------------
776
+
777
+ function handleTimeZone(url: URL): GoogleMapsResponse {
778
+ const loc = parseLatLng(url.searchParams.get('location'));
779
+ const ts = url.searchParams.get('timestamp');
780
+ if (!loc) return maps('INVALID_REQUEST', {}, 'Invalid or missing `location` parameter.');
781
+ if (ts === null || !Number.isFinite(Number(ts))) return maps('INVALID_REQUEST', {}, 'Invalid or missing `timestamp` parameter.');
782
+ const z = deterministicTimeZone(loc, Number(ts));
783
+ return maps('OK', { dstOffset: z.dstOffset, rawOffset: z.rawOffset, timeZoneId: z.timeZoneId, timeZoneName: z.timeZoneName });
784
+ }
785
+
786
+ function handleElevation(url: URL): GoogleMapsResponse {
787
+ const locations = url.searchParams.get('locations');
788
+ const path = url.searchParams.get('path');
789
+ const samples = url.searchParams.get('samples');
790
+ if (!locations && !path) return maps('INVALID_REQUEST', { results: [] }, 'Invalid request. Missing `locations` or `path`.');
791
+ let pts: (LatLng | null)[];
792
+ if (locations) {
793
+ pts = locations.split('|').map(parseLatLng);
794
+ } else {
795
+ if (!samples || !Number.isFinite(Number(samples))) return maps('INVALID_REQUEST', { results: [] }, '`path` requests require `samples`.');
796
+ pts = path!.split('|').map(parseLatLng);
797
+ }
798
+ if (pts.some((p) => p === null)) return maps('INVALID_REQUEST', { results: [] }, 'Invalid location in request.');
799
+ const results = (pts as LatLng[]).map((loc) => ({ elevation: deterministicElevation(loc), location: loc, resolution: 4.771975994110107 }));
800
+ return maps('OK', { results });
801
+ }
802
+
803
+ function handleGeolocation(body: Body): GoogleMapsResponse {
804
+ // Geolocation accepts cellTowers / wifiAccessPoints; empty body falls back to a
805
+ // deterministic location with a low accuracy, faithful to the real API.
806
+ const seed = JSON.stringify(body?.wifiAccessPoints ?? body?.cellTowers ?? body?.considerIp ?? 'default');
807
+ const loc = deterministicLatLng(seed);
808
+ return { status: 200, body: { location: { lat: loc.lat, lng: loc.lng }, accuracy: 1500 } };
809
+ }
810
+
811
+ // ---- Static Maps / Street View Static (binary surfaces) --------------------
812
+
813
+ function handleStaticMap(url: URL): GoogleMapsResponse {
814
+ const center = url.searchParams.get('center');
815
+ const markers = url.searchParams.get('markers');
816
+ const size = url.searchParams.get('size');
817
+ if (!size) return rpc(400, 400, 'INVALID_ARGUMENT', 'Missing the required `size` parameter.');
818
+ if (!center && !markers && !url.searchParams.get('path')) {
819
+ return rpc(400, 400, 'INVALID_ARGUMENT', 'Static map requests must include `center` (with `zoom`), `markers`, or `path`.');
820
+ }
821
+ // A twin returns image METADATA (incl. parsed styling) rather than real pixels.
822
+ const maptype = url.searchParams.get('maptype') ?? 'roadmap';
823
+ const validMaptypes = ['roadmap', 'satellite', 'terrain', 'hybrid'];
824
+ if (!validMaptypes.includes(maptype)) return rpc(400, 400, 'INVALID_ARGUMENT', `Invalid maptype: ${maptype}`);
825
+ return {
826
+ status: 200,
827
+ body: {
828
+ contentType: 'image/png',
829
+ size,
830
+ center,
831
+ maptype,
832
+ markers: url.searchParams.getAll('markers'),
833
+ path: url.searchParams.getAll('path'),
834
+ style: url.searchParams.getAll('style'),
835
+ scale: url.searchParams.get('scale') ? Number(url.searchParams.get('scale')) : 1,
836
+ twinNote: 'twin returns image metadata, not real imagery pixels',
837
+ },
838
+ };
839
+ }
840
+
841
+ function handleStreetView(url: URL): GoogleMapsResponse {
842
+ const location = url.searchParams.get('location');
843
+ const pano = url.searchParams.get('pano');
844
+ const size = url.searchParams.get('size');
845
+ if (!size) return rpc(400, 400, 'INVALID_ARGUMENT', 'Missing the required `size` parameter.');
846
+ if (!location && !pano) return rpc(400, 400, 'INVALID_ARGUMENT', 'Street View requests must include `location` or `pano`.');
847
+ return { status: 200, body: { contentType: 'image/jpeg', size, twinNote: 'twin returns image metadata, not real imagery pixels' } };
848
+ }
849
+
850
+ function handleStreetViewMetadata(url: URL): GoogleMapsResponse {
851
+ const location = url.searchParams.get('location');
852
+ const pano = url.searchParams.get('pano');
853
+ if (!location && !pano) return maps('INVALID_REQUEST', {}, 'Street View metadata requires `location` or `pano`.');
854
+ const loc = parseLatLng(location) ?? deterministicLatLng(location ?? pano ?? '');
855
+ if (location && isZeroResultsQuery(location)) return maps('ZERO_RESULTS', {});
856
+ return maps('OK', {
857
+ copyright: '© Google (twin)',
858
+ date: '2024-01',
859
+ location: { lat: loc.lat, lng: loc.lng },
860
+ pano_id: pano ?? `pano_${stableHash(`${loc.lat},${loc.lng}`)}`,
861
+ });
862
+ }
863
+
864
+ // ---- Address Validation (v1, header-key) -----------------------------------
865
+
866
+ function handleAddressValidation(body: Body): GoogleMapsResponse {
867
+ const addr = body?.address;
868
+ const lines: string[] = Array.isArray(addr?.addressLines) ? addr.addressLines : [];
869
+ if (lines.length === 0) return rpc(400, 400, 'INVALID_ARGUMENT', 'address.addressLines must be non-empty.');
870
+ const joined = lines.join(', ');
871
+ const p = findPlaceByText(joined);
872
+ const loc = p?.location ?? deterministicLatLng(normalizeAddress(joined));
873
+ const verdict = p
874
+ ? { inputGranularity: 'PREMISE', validationGranularity: 'PREMISE', geocodeGranularity: 'PREMISE', addressComplete: true, hasUnconfirmedComponents: false }
875
+ : { inputGranularity: 'ROUTE', validationGranularity: 'ROUTE', geocodeGranularity: 'ROUTE', addressComplete: false, hasUnconfirmedComponents: true };
876
+ // Per-component confirmationLevel: confirmed for canned places, unconfirmed otherwise.
877
+ const confirmationLevel = p ? 'CONFIRMED' : 'UNCONFIRMED_BUT_PLAUSIBLE';
878
+ const sourceComponents = p ? p.components : [
879
+ { long_name: lines[0] ?? joined, short_name: lines[0] ?? joined, types: ['route'] },
880
+ ];
881
+ const addressComponents = sourceComponents.map((c) => ({
882
+ componentName: { text: c.long_name, languageCode: 'en' },
883
+ componentType: c.types[0] ?? 'route',
884
+ confirmationLevel,
885
+ inferred: false,
886
+ spellCorrected: false,
887
+ }));
888
+ // USPS CASS metadata is only populated for US addresses.
889
+ const isUS = (p ? countryOf(p) === 'us' : /\b(usa|united states)\b/i.test(joined) || lines.some((l) => /\b[A-Z]{2}\s*\d{5}\b/.test(l)));
890
+ const uspsData = isUS
891
+ ? { standardizedAddress: { firstAddressLine: lines[0] ?? joined, cityStateZipAddressLine: 'MOUNTAIN VIEW CA 94043-1351', city: 'MOUNTAIN VIEW', state: 'CA', zipCode: '94043', zipCodeExtension: '1351' }, deliveryPointCode: '99', dpvConfirmation: p ? 'Y' : 'N', carrierRoute: 'C909' }
892
+ : undefined;
893
+ return {
894
+ status: 200,
895
+ body: {
896
+ result: {
897
+ verdict,
898
+ address: { formattedAddress: p?.formatted_address ?? joined, postalAddress: { addressLines: lines }, addressComponents },
899
+ geocode: { location: { latitude: loc.lat, longitude: loc.lng }, placeId: p?.place_id ?? `ChIJ${stableHash(joined)}twin` },
900
+ ...(uspsData ? { uspsData } : {}),
901
+ },
902
+ responseId: `resp_${stableHash(joined)}`,
903
+ },
904
+ };
905
+ }
906
+
907
+ // ---- Routes API (New) — v2, header-key, google.rpc.Status errors -----------
908
+
909
+ /** Resolve a Routes-API waypoint ({location:{latLng}} | {address} | {placeId}) to a LatLng. */
910
+ function routesWaypointLoc(wp: Body | undefined): LatLng | null {
911
+ if (!wp || typeof wp !== 'object') return null;
912
+ const latLng = wp.location?.latLng;
913
+ if (latLng && typeof latLng.latitude === 'number' && typeof latLng.longitude === 'number') {
914
+ return { lat: latLng.latitude, lng: latLng.longitude };
915
+ }
916
+ if (typeof wp.address === 'string' && wp.address.trim()) {
917
+ if (isZeroResultsQuery(wp.address)) return null;
918
+ return locOf(wp.address);
919
+ }
920
+ if (typeof wp.placeId === 'string') {
921
+ const p = findPlaceById(wp.placeId);
922
+ if (p) return p.location;
923
+ return deterministicLatLng(wp.placeId);
924
+ }
925
+ return null;
926
+ }
927
+
928
+ function handleComputeRoutes(body: Body, mask: string | undefined): GoogleMapsResponse {
929
+ const oLoc = routesWaypointLoc(body.origin);
930
+ const dLoc = routesWaypointLoc(body.destination);
931
+ if (!body.origin || !body.destination) {
932
+ return rpc(400, 400, 'INVALID_ARGUMENT', 'Both origin and destination are required.');
933
+ }
934
+ if (!oLoc || !dLoc) {
935
+ // A well-formed but unroutable request → 200 with an empty routes set.
936
+ return { status: 200, body: {} };
937
+ }
938
+ const modeEnum = typeof body.travelMode === 'string' ? body.travelMode.toUpperCase() : 'DRIVE';
939
+ const speedMap: Record<string, number> = { DRIVE: 40, WALK: 5, BICYCLE: 15, TRANSIT: 30, TWO_WHEELER: 35 };
940
+ const speed = speedMap[modeEnum] ?? 40;
941
+ const intermediates: LatLng[] = Array.isArray(body.intermediates)
942
+ ? body.intermediates.map((w: Body) => routesWaypointLoc(w)).filter((l: LatLng | null): l is LatLng => !!l)
943
+ : [];
944
+ const pts = [oLoc, ...intermediates, dLoc];
945
+ let meters = 0;
946
+ for (let i = 0; i < pts.length - 1; i++) meters += Math.max(1, haversineMeters(pts[i]!, pts[i + 1]!));
947
+ const secs = Math.max(60, Math.round((meters / 1000 / speed) * 3600));
948
+ const buildOne = (distM: number, durS: number): Body => ({
949
+ distanceMeters: distM,
950
+ duration: `${durS}s`,
951
+ polyline: { encodedPolyline: encodePolyline(pts) },
952
+ legs: (() => {
953
+ const legs: Body[] = [];
954
+ for (let i = 0; i < pts.length - 1; i++) {
955
+ const lm = Math.max(1, haversineMeters(pts[i]!, pts[i + 1]!));
956
+ const ls = Math.max(60, Math.round((lm / 1000 / speed) * 3600));
957
+ legs.push({
958
+ distanceMeters: lm,
959
+ duration: `${ls}s`,
960
+ polyline: { encodedPolyline: encodePolyline([pts[i]!, pts[i + 1]!]) },
961
+ startLocation: { latLng: { latitude: pts[i]!.lat, longitude: pts[i]!.lng } },
962
+ endLocation: { latLng: { latitude: pts[i + 1]!.lat, longitude: pts[i + 1]!.lng } },
963
+ });
964
+ }
965
+ return legs;
966
+ })(),
967
+ travelAdvisory: {},
968
+ routeLabels: [distM === meters ? 'DEFAULT_ROUTE' : 'DEFAULT_ROUTE_ALTERNATE'],
969
+ });
970
+ const routes: Body[] = [buildOne(meters, secs)];
971
+ if (body.computeAlternativeRoutes === true) {
972
+ routes.push(buildOne(Math.round(meters * 1.1), Math.round(secs * 1.15)));
973
+ }
974
+ return { status: 200, body: applyFieldMask({ routes }, mask) };
975
+ }
976
+
977
+ function handleComputeRouteMatrix(body: Body, mask: string | undefined): GoogleMapsResponse {
978
+ const origins: Body[] = Array.isArray(body.origins) ? body.origins : [];
979
+ const destinations: Body[] = Array.isArray(body.destinations) ? body.destinations : [];
980
+ if (origins.length === 0 || destinations.length === 0) {
981
+ return rpc(400, 400, 'INVALID_ARGUMENT', 'origins and destinations are required.');
982
+ }
983
+ const modeEnum = typeof body.travelMode === 'string' ? body.travelMode.toUpperCase() : 'DRIVE';
984
+ const speedMap: Record<string, number> = { DRIVE: 40, WALK: 5, BICYCLE: 15, TRANSIT: 30, TWO_WHEELER: 35 };
985
+ const speed = speedMap[modeEnum] ?? 40;
986
+ const oLocs = origins.map((o) => routesWaypointLoc(o.waypoint ?? o));
987
+ const dLocs = destinations.map((d) => routesWaypointLoc(d.waypoint ?? d));
988
+ const elements: Body[] = [];
989
+ for (let i = 0; i < oLocs.length; i++) {
990
+ for (let j = 0; j < dLocs.length; j++) {
991
+ const o = oLocs[i];
992
+ const d = dLocs[j];
993
+ if (!o || !d) {
994
+ elements.push({ originIndex: i, destinationIndex: j, status: {}, condition: 'ROUTE_NOT_FOUND' });
995
+ continue;
996
+ }
997
+ const m = Math.max(1, haversineMeters(o, d));
998
+ const s = Math.max(60, Math.round((m / 1000 / speed) * 3600));
999
+ elements.push({ originIndex: i, destinationIndex: j, status: {}, distanceMeters: m, duration: `${s}s`, condition: 'ROUTE_EXISTS' });
1000
+ }
1001
+ }
1002
+ // computeRouteMatrix returns a bare JSON array (REST). Field mask applies per element.
1003
+ const masked = mask && mask.trim() && mask.trim() !== '*'
1004
+ ? elements.map((e) => applyFieldMask(e, mask))
1005
+ : elements;
1006
+ return { status: 200, body: masked };
1007
+ }
1008
+
1009
+ // ---- dispatch --------------------------------------------------------------
1010
+
1011
+ export async function handleGoogleMapsTwinRequest(req: GoogleMapsRequest): Promise<GoogleMapsResponse> {
1012
+ const method = req.method.toUpperCase();
1013
+ const url = new URL(req.path, 'http://twin.local');
1014
+ let path = url.pathname;
1015
+ // Accept the proxy-prefixed form too.
1016
+ path = path.replace(/^\/api(?=\/)/, '');
1017
+
1018
+ // Legacy web-service APIs gate on `key=`; New/v1/v2 APIs gate on X-Goog-Api-Key.
1019
+ const isV1 = path.startsWith('/v1/') || path.startsWith('/v1:') || path.startsWith('/v1beta') || path.startsWith('/v2:') || path.includes(':computeRoutes') || path.includes(':computeRouteMatrix') || path.includes(':searchText') || path.includes(':searchNearby') || path.includes(':autocomplete');
1020
+ const auth = authenticate(url, req.headers);
1021
+ if (!auth.ok) {
1022
+ if (isV1) return rpc(403, 403, 'PERMISSION_DENIED', auth.message);
1023
+ return maps(auth.status, {}, auth.message);
1024
+ }
1025
+ // Field mask header (Places API New / Routes API): X-Goog-FieldMask.
1026
+ const fieldMask = header(req.headers, 'x-goog-fieldmask');
1027
+
1028
+ // Twin-only inspection surface.
1029
+ if (method === 'GET' && path === '/twin/audit') return { status: 200, body: { entries: auditRows(req.root) } };
1030
+
1031
+ // Twin-only: seed a geocode override (used by the connector / tests).
1032
+ if (method === 'POST' && path === '/twin/geocode') {
1033
+ if (req.readOnly) return { status: 405, body: { error: { code: 405, status: 'METHOD_NOT_ALLOWED', message: 'twin is read-only' } } };
1034
+ const body = parseBody(req.body);
1035
+ const address = String(body.address ?? '');
1036
+ const result = body.result as GeocodeResult | undefined;
1037
+ if (!address || !result) return { status: 400, body: { error: { code: 400, status: 'INVALID_ARGUMENT', message: 'address and result are required' } } };
1038
+ const norm = normalizeAddress(address);
1039
+ const id = `geocode_${stableHash(norm)}`;
1040
+ const row = await applyTwinWrite(SERVICE, {
1041
+ operation: 'geocode.upsert',
1042
+ subjectType: 'geocode',
1043
+ subjectId: id,
1044
+ fields: { id, normalized: norm, address, result },
1045
+ occurredAt: req.occurredAt ?? FIXED_NOW,
1046
+ actor: { kind: 'system' },
1047
+ }, req.root);
1048
+ return { status: 200, body: row };
1049
+ }
1050
+
1051
+ // ----- Legacy Maps web services (maps/api/...) -----
1052
+ if (method === 'GET') {
1053
+ if (path === '/maps/api/geocode/json') {
1054
+ const res = url.searchParams.get('latlng') ? handleReverseGeocode(url) : handleGeocode(url, req.root);
1055
+ await recordAudit(req, { api: 'geocode', status: String((res.body as Body).status), query: url.searchParams.get('address') ?? url.searchParams.get('latlng') ?? '' });
1056
+ return res;
1057
+ }
1058
+ if (path === '/maps/api/distancematrix/json') {
1059
+ const res = handleDistanceMatrix(url);
1060
+ await recordAudit(req, { api: 'distancematrix', status: String((res.body as Body).status) });
1061
+ return res;
1062
+ }
1063
+ if (path === '/maps/api/directions/json') {
1064
+ const res = handleDirections(url);
1065
+ await recordAudit(req, { api: 'directions', status: String((res.body as Body).status) });
1066
+ return res;
1067
+ }
1068
+ if (path === '/maps/api/place/findplacefromtext/json') return handleFindPlace(url);
1069
+ if (path === '/maps/api/place/textsearch/json') return handleTextSearchLegacy(url);
1070
+ if (path === '/maps/api/place/nearbysearch/json') return handleNearbySearch(url);
1071
+ if (path === '/maps/api/place/details/json') return handlePlaceDetails(url);
1072
+ if (path === '/maps/api/place/photo') return handlePlacePhoto(url);
1073
+ if (path === '/maps/api/place/autocomplete/json') return handleAutocomplete(url);
1074
+ if (path === '/maps/api/place/queryautocomplete/json') return handleQueryAutocomplete(url);
1075
+ if (path === '/maps/api/timezone/json') return handleTimeZone(url);
1076
+ if (path === '/maps/api/elevation/json') return handleElevation(url);
1077
+ if (path === '/maps/api/staticmap') return handleStaticMap(url);
1078
+ if (path === '/maps/api/streetview') return handleStreetView(url);
1079
+ if (path === '/maps/api/streetview/metadata') return handleStreetViewMetadata(url);
1080
+ // Roads
1081
+ if (path === '/v1/snapToRoads') return handleSnapToRoads(url);
1082
+ if (path === '/v1/nearestRoads') return handleNearestRoads(url);
1083
+ if (path === '/v1/speedLimits') return handleSpeedLimits(url);
1084
+ }
1085
+
1086
+ if (method === 'POST') {
1087
+ if (path === '/maps/api/geolocation/json' || path === '/geolocation/v1/geolocate') return handleGeolocation(parseBody(req.body));
1088
+ // Places API (New)
1089
+ if (path === '/v1/places:searchText') return handleNewTextSearch(parseBody(req.body), fieldMask);
1090
+ if (path === '/v1/places:searchNearby') return handleNewNearbySearch(parseBody(req.body), fieldMask);
1091
+ if (path === '/v1/places:autocomplete') return handleNewAutocomplete(parseBody(req.body));
1092
+ // Address Validation
1093
+ if (path === '/v1:validateAddress') return handleAddressValidation(parseBody(req.body));
1094
+ // Routes API (New) — v2
1095
+ if (path === '/directions/v2:computeRoutes' || path === '/v2:computeRoutes') return handleComputeRoutes(parseBody(req.body), fieldMask);
1096
+ if (path === '/distanceMatrix/v2:computeRouteMatrix' || path === '/v2:computeRouteMatrix') return handleComputeRouteMatrix(parseBody(req.body), fieldMask);
1097
+ }
1098
+
1099
+ // Place Photo media (New): GET /v1/places/{place_id}/photos/{photo}/media
1100
+ const newPhotoMatch = path.match(/^\/v1\/places\/([^/:]+)\/photos\/([^/:]+)\/media$/);
1101
+ if (method === 'GET' && newPhotoMatch) return handleNewPlacePhotoMedia(url, decodeURIComponent(newPhotoMatch[1]!), decodeURIComponent(newPhotoMatch[2]!));
1102
+
1103
+ // Place Details (New): GET /v1/places/{place_id}
1104
+ const newDetailsMatch = path.match(/^\/v1\/places\/([^/:]+)$/);
1105
+ if (method === 'GET' && newDetailsMatch) return handleNewPlaceDetails(decodeURIComponent(newDetailsMatch[1]!), fieldMask);
1106
+
1107
+ // Unmodeled op fails like the vendor.
1108
+ if (isV1) return rpc(404, 404, 'NOT_FOUND', `Operation not modeled: ${method} ${path}`);
1109
+ return maps('INVALID_REQUEST', {}, `Operation not modeled: ${method} ${path}`);
1110
+ }