@f5-sales-demo/xcsh 20.18.0 → 20.19.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## Unreleased
6
+
7
+ ### Added
8
+
9
+ - Added provenance-validated, deterministic `render_map` PNG generation with OpenStreetMap tile
10
+ policy compliance, schematic and text fallbacks, and optional atomic PNG/GeoJSON export
11
+ ([#3202](https://github.com/f5-sales-demo/xcsh/issues/3202)).
12
+
13
+ ### Changed
14
+
15
+ - Unified generated and existing media publication behind the versioned `xcsh.media/v1` result
16
+ contract so TUI, Browser, Office, RPC, and ACP transports no longer identify media by tool name
17
+ ([#3202](https://github.com/f5-sales-demo/xcsh/issues/3202)).
18
+
5
19
  ## [20.6.3] - 2026-08-07
6
20
 
7
21
  ### Improved
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "20.18.0",
4
+ "version": "20.19.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -61,13 +61,13 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@agentclientprotocol/sdk": "1.3.0",
64
- "@f5-sales-demo/pi-agent-core": "20.18.0",
65
- "@f5-sales-demo/pi-ai": "20.18.0",
66
- "@f5-sales-demo/pi-natives": "20.18.0",
67
- "@f5-sales-demo/pi-resource-management": "20.18.0",
68
- "@f5-sales-demo/pi-tui": "20.18.0",
69
- "@f5-sales-demo/pi-utils": "20.18.0",
70
- "@f5-sales-demo/xcsh-stats": "20.18.0",
64
+ "@f5-sales-demo/pi-agent-core": "20.19.0",
65
+ "@f5-sales-demo/pi-ai": "20.19.0",
66
+ "@f5-sales-demo/pi-natives": "20.19.0",
67
+ "@f5-sales-demo/pi-resource-management": "20.19.0",
68
+ "@f5-sales-demo/pi-tui": "20.19.0",
69
+ "@f5-sales-demo/pi-utils": "20.19.0",
70
+ "@f5-sales-demo/xcsh-stats": "20.19.0",
71
71
  "@mozilla/readability": "^0.6",
72
72
  "@sinclair/typebox": "^0.34",
73
73
  "@xterm/headless": "^6.0",
@@ -891,7 +891,8 @@
891
891
  "const": "tool"
892
892
  },
893
893
  "source": {
894
- "const": "display_media"
894
+ "type": "string",
895
+ "pattern": "^[a-z][a-z0-9_]{0,63}$"
895
896
  }
896
897
  }
897
898
  },
@@ -942,6 +943,23 @@
942
943
  },
943
944
  "degradation": {
944
945
  "type": "string"
946
+ },
947
+ "filenameHint": {
948
+ "type": "string",
949
+ "minLength": 1,
950
+ "maxLength": 255,
951
+ "pattern": "^[^/\\\\]+$"
952
+ },
953
+ "metadata": {
954
+ "type": "object",
955
+ "maxProperties": 16,
956
+ "propertyNames": {
957
+ "pattern": "^[a-zA-Z0-9_.-]{1,64}$"
958
+ },
959
+ "additionalProperties": {
960
+ "type": "string",
961
+ "maxLength": 512
962
+ }
945
963
  }
946
964
  }
947
965
  }
@@ -334,15 +334,13 @@ export class ChatHandler {
334
334
  return;
335
335
  }
336
336
  if (event.type === "tool_execution_end" && "toolName" in event) {
337
- if (event.toolName === "display_media" || event.toolName === "display_image") {
338
- const descriptor = extractMediaDescriptorFromToolResult(event.result);
339
- if (descriptor) {
340
- this.#server.send({
341
- type: "chat_media",
342
- id: chat.id,
343
- media: projectMediaDescriptorForTransport(descriptor),
344
- } satisfies ChatMedia);
345
- }
337
+ const descriptor = extractMediaDescriptorFromToolResult(event.result);
338
+ if (descriptor) {
339
+ this.#server.send({
340
+ type: "chat_media",
341
+ id: chat.id,
342
+ media: projectMediaDescriptorForTransport(descriptor),
343
+ } satisfies ChatMedia);
346
344
  }
347
345
  // ToolExecutionEndEvent carries `isError` (NOT `error`) — checking the wrong
348
346
  // field made this always ok:true, so errored tools rendered ✓ and "failed"
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.18.0",
21
- "commit": "bb8af0601559effb96162735f79350a7d6beb9b0",
22
- "shortCommit": "bb8af06",
20
+ "version": "20.19.0",
21
+ "commit": "cd85a8290bc927a2beeb4d58f0945209b73c0259",
22
+ "shortCommit": "cd85a82",
23
23
  "branch": "main",
24
- "tag": "v20.18.0",
25
- "commitDate": "2026-08-15T07:52:07Z",
26
- "buildDate": "2026-08-15T08:18:07.894Z",
24
+ "tag": "v20.19.0",
25
+ "commitDate": "2026-08-15T14:43:24Z",
26
+ "buildDate": "2026-08-15T15:12:02.480Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/bb8af0601559effb96162735f79350a7d6beb9b0",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.18.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/cd85a8290bc927a2beeb4d58f0945209b73c0259",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.19.0"
33
33
  };
@@ -0,0 +1,314 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { getPuppeteerDir } from "@f5-sales-demo/pi-utils";
4
+ import type { Browser, Page, default as Puppeteer } from "puppeteer";
5
+ import { locateChrome } from "../browser/chrome-locate";
6
+ import { OsmTileFetcher, type TileRequest } from "./tile-cache";
7
+ import { type FittedMapLocations, fitMapLocations, type MapLocationV1 } from "./types";
8
+
9
+ export const MAP_WIDTH = 1200;
10
+ export const MAP_HEIGHT = 760;
11
+ const PLOT = { x: 32, y: 72, width: 1136, height: 590 };
12
+ const MAX_VIEWPORT_TILES = 48;
13
+
14
+ export interface RenderMapRequest {
15
+ title: string;
16
+ locations: MapLocationV1[];
17
+ basemap: "osm" | "schematic";
18
+ signal?: AbortSignal;
19
+ settings?: { get(key: string): unknown };
20
+ }
21
+
22
+ export interface RenderedMap {
23
+ png: Buffer;
24
+ basemap: "osm" | "schematic";
25
+ degradation?: string;
26
+ }
27
+
28
+ export interface RenderMapDependencies {
29
+ tileFetcher?: OsmTileFetcher;
30
+ rasterize?: (svg: string, settings?: { get(key: string): unknown }, signal?: AbortSignal) => Promise<Buffer>;
31
+ }
32
+
33
+ interface Viewport {
34
+ minX: number;
35
+ maxX: number;
36
+ minY: number;
37
+ maxY: number;
38
+ spanX: number;
39
+ spanY: number;
40
+ }
41
+
42
+ interface TileImage extends TileRequest {
43
+ logicalX: number;
44
+ data: Buffer;
45
+ }
46
+
47
+ function escapeXml(value: string): string {
48
+ return value.replace(/[&<>"']/gu, character => {
49
+ switch (character) {
50
+ case "&":
51
+ return "&amp;";
52
+ case "<":
53
+ return "&lt;";
54
+ case ">":
55
+ return "&gt;";
56
+ case '"':
57
+ return "&quot;";
58
+ default:
59
+ return "&apos;";
60
+ }
61
+ });
62
+ }
63
+
64
+ function viewportFor(fit: FittedMapLocations): Viewport {
65
+ const paddingX = Math.max(fit.spanX * 0.12, 1 / 512);
66
+ const paddingY = Math.max(fit.spanY * 0.12, 1 / 512);
67
+ let minX = fit.minX - paddingX;
68
+ let maxX = fit.maxX + paddingX;
69
+ let minY = Math.max(0, fit.minY - paddingY);
70
+ let maxY = Math.min(1, fit.maxY + paddingY);
71
+ const desiredAspect = PLOT.width / PLOT.height;
72
+ const currentAspect = (maxX - minX) / (maxY - minY);
73
+ if (currentAspect < desiredAspect) {
74
+ const extra = ((maxY - minY) * desiredAspect - (maxX - minX)) / 2;
75
+ minX -= extra;
76
+ maxX += extra;
77
+ } else {
78
+ const extra = ((maxX - minX) / desiredAspect - (maxY - minY)) / 2;
79
+ minY = Math.max(0, minY - extra);
80
+ maxY = Math.min(1, maxY + extra);
81
+ }
82
+ return { minX, maxX, minY, maxY, spanX: maxX - minX, spanY: maxY - minY };
83
+ }
84
+
85
+ function tileRequests(viewport: Viewport): Array<Omit<TileImage, "data">> {
86
+ let zoom = Math.max(
87
+ 1,
88
+ Math.min(
89
+ 12,
90
+ Math.floor(Math.log2(Math.min(PLOT.width / (viewport.spanX * 256), PLOT.height / (viewport.spanY * 256)))),
91
+ ),
92
+ );
93
+ for (;;) {
94
+ const count = 2 ** zoom;
95
+ const minLogicalX = Math.floor(viewport.minX * count);
96
+ const maxLogicalX = Math.floor(viewport.maxX * count - Number.EPSILON);
97
+ const minY = Math.max(0, Math.floor(viewport.minY * count));
98
+ const maxY = Math.min(count - 1, Math.floor(viewport.maxY * count - Number.EPSILON));
99
+ const requests: Array<Omit<TileImage, "data">> = [];
100
+ for (let y = minY; y <= maxY; y++) {
101
+ for (let logicalX = minLogicalX; logicalX <= maxLogicalX; logicalX++) {
102
+ requests.push({ z: zoom, x: ((logicalX % count) + count) % count, y, logicalX });
103
+ }
104
+ }
105
+ if (requests.length <= MAX_VIEWPORT_TILES || zoom === 1) return requests;
106
+ zoom--;
107
+ }
108
+ }
109
+
110
+ async function loadTiles(viewport: Viewport, fetcher: OsmTileFetcher, signal?: AbortSignal): Promise<TileImage[]> {
111
+ const requests = tileRequests(viewport);
112
+ const result: TileImage[] = [];
113
+ // Deliberately bounded and sequential: viewport tiles only, with no prefetch or bulk endpoint behavior.
114
+ for (const request of requests) {
115
+ signal?.throwIfAborted();
116
+ result.push({ ...request, data: await fetcher.fetchTile(request, signal) });
117
+ }
118
+ return result;
119
+ }
120
+
121
+ function screenPoint(worldX: number, worldY: number, viewport: Viewport): { x: number; y: number } {
122
+ return {
123
+ x: PLOT.x + ((worldX - viewport.minX) / viewport.spanX) * PLOT.width,
124
+ y: PLOT.y + ((worldY - viewport.minY) / viewport.spanY) * PLOT.height,
125
+ };
126
+ }
127
+
128
+ function schematicBackground(): string {
129
+ const lines: string[] = [
130
+ `<rect x="${PLOT.x}" y="${PLOT.y}" width="${PLOT.width}" height="${PLOT.height}" fill="#eef4f7"/>`,
131
+ ];
132
+ for (let index = 1; index < 8; index++) {
133
+ const x = PLOT.x + (PLOT.width * index) / 8;
134
+ lines.push(`<line x1="${x}" y1="${PLOT.y}" x2="${x}" y2="${PLOT.y + PLOT.height}"/>`);
135
+ }
136
+ for (let index = 1; index < 5; index++) {
137
+ const y = PLOT.y + (PLOT.height * index) / 5;
138
+ lines.push(`<line x1="${PLOT.x}" y1="${y}" x2="${PLOT.x + PLOT.width}" y2="${y}"/>`);
139
+ }
140
+ return `<g stroke="#cbd9df" stroke-width="1">${lines.join("")}</g>`;
141
+ }
142
+
143
+ function tileBackground(tiles: TileImage[], viewport: Viewport): string {
144
+ return tiles
145
+ .map(tile => {
146
+ const count = 2 ** tile.z;
147
+ const x = PLOT.x + (tile.logicalX / count - viewport.minX) * (PLOT.width / viewport.spanX);
148
+ const y = PLOT.y + (tile.y / count - viewport.minY) * (PLOT.height / viewport.spanY);
149
+ const width = PLOT.width / (count * viewport.spanX);
150
+ const height = PLOT.height / (count * viewport.spanY);
151
+ return `<image x="${x}" y="${y}" width="${width}" height="${height}" href="data:image/png;base64,${tile.data.toString("base64")}"/>`;
152
+ })
153
+ .join("");
154
+ }
155
+
156
+ interface MarkerGroup {
157
+ x: number;
158
+ y: number;
159
+ indexes: number[];
160
+ locations: MapLocationV1[];
161
+ }
162
+
163
+ function groupMarkers(fit: FittedMapLocations, viewport: Viewport, original: readonly MapLocationV1[]): MarkerGroup[] {
164
+ const order = new Map(original.map((location, index) => [location.id, index + 1]));
165
+ const groups: MarkerGroup[] = [];
166
+ for (const point of [...fit.locations].sort((a, b) => a.location.id.localeCompare(b.location.id))) {
167
+ const screen = screenPoint(point.worldX, point.worldY, viewport);
168
+ const group = groups.find(candidate => Math.hypot(candidate.x - screen.x, candidate.y - screen.y) < 30);
169
+ if (group) {
170
+ group.locations.push(point.location);
171
+ group.indexes.push(order.get(point.location.id)!);
172
+ group.x = (group.x * (group.locations.length - 1) + screen.x) / group.locations.length;
173
+ group.y = (group.y * (group.locations.length - 1) + screen.y) / group.locations.length;
174
+ } else {
175
+ groups.push({
176
+ x: screen.x,
177
+ y: screen.y,
178
+ indexes: [order.get(point.location.id)!],
179
+ locations: [point.location],
180
+ });
181
+ }
182
+ }
183
+ for (const group of groups) group.indexes.sort((a, b) => a - b);
184
+ return groups;
185
+ }
186
+
187
+ function markerSvg(groups: MarkerGroup[]): string {
188
+ const labels: Array<{ x: number; y: number; width: number; height: number }> = [];
189
+ return groups
190
+ .map((group, groupIndex) => {
191
+ const primary = group.locations[0]!;
192
+ const fill =
193
+ primary.confidence === "high" ? "#0b6bcb" : primary.confidence === "medium" ? "#c76a00" : "#7b4ba3";
194
+ const dash = ["city", "metro", "approximate", "inferred"].includes(primary.precision)
195
+ ? ' stroke-dasharray="4 2"'
196
+ : "";
197
+ const markerText = group.indexes.join(",");
198
+ const labelText = group.locations.map(location => location.label).join(" / ");
199
+ const labelWidth = Math.min(330, Math.max(100, labelText.length * 7.2 + 18));
200
+ const labelX = Math.min(PLOT.x + PLOT.width - labelWidth - 4, group.x + 17);
201
+ let labelY = Math.max(PLOT.y + 4, group.y - 15);
202
+ let attempts = 0;
203
+ while (
204
+ attempts < 20 &&
205
+ labels.some(
206
+ label => Math.abs(label.x - labelX) < (label.width + labelWidth) / 2 && Math.abs(label.y - labelY) < 28,
207
+ )
208
+ ) {
209
+ attempts++;
210
+ labelY += 28;
211
+ if (labelY > PLOT.y + PLOT.height - 25) {
212
+ labelY = Math.max(PLOT.y + 4, group.y - 15 - attempts * 28 - groupIndex * 2);
213
+ }
214
+ }
215
+ labels.push({ x: labelX, y: labelY, width: labelWidth, height: 24 });
216
+ return `<g><circle cx="${group.x}" cy="${group.y}" r="13" fill="${fill}" stroke="#fff" stroke-width="3"${dash}/><text x="${group.x}" y="${group.y + 4}" text-anchor="middle" class="marker">${escapeXml(markerText)}</text><rect x="${labelX}" y="${labelY}" width="${labelWidth}" height="24" rx="5" fill="#ffffff" fill-opacity="0.92" stroke="#66747b"/><text x="${labelX + 8}" y="${labelY + 16}" class="label">${escapeXml(labelText.slice(0, 44))}</text></g>`;
217
+ })
218
+ .join("");
219
+ }
220
+
221
+ export function buildMapSvg(
222
+ title: string,
223
+ locations: readonly MapLocationV1[],
224
+ basemap: "osm" | "schematic",
225
+ tiles: TileImage[] = [],
226
+ ): string {
227
+ const fit = fitMapLocations(locations);
228
+ const viewport = viewportFor(fit);
229
+ const background = basemap === "osm" ? tileBackground(tiles, viewport) : schematicBackground();
230
+ const attribution =
231
+ basemap === "osm"
232
+ ? "© OpenStreetMap contributors • openstreetmap.org/copyright"
233
+ : "Schematic Web Mercator • no basemap tiles";
234
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${MAP_WIDTH}" height="${MAP_HEIGHT}" viewBox="0 0 ${MAP_WIDTH} ${MAP_HEIGHT}"><style>text{font-family:Arial,sans-serif;fill:#17242b}.title{font-size:26px;font-weight:700}.label{font-size:13px}.marker{font-size:10px;font-weight:700;fill:#fff}.legend{font-size:13px}.attribution{font-size:12px;fill:#394b54}</style><rect width="1200" height="760" fill="#fff"/><text x="32" y="42" class="title">${escapeXml(title)}</text><defs><clipPath id="plot"><rect x="${PLOT.x}" y="${PLOT.y}" width="${PLOT.width}" height="${PLOT.height}"/></clipPath></defs><g clip-path="url(#plot)">${background}</g><rect x="${PLOT.x}" y="${PLOT.y}" width="${PLOT.width}" height="${PLOT.height}" fill="none" stroke="#50636c"/>${markerSvg(groupMarkers(fit, viewport, locations))}<g class="legend"><circle cx="45" cy="698" r="8" fill="#0b6bcb"/><text x="59" y="703">high confidence</text><circle cx="190" cy="698" r="8" fill="#c76a00"/><text x="204" y="703">medium</text><circle cx="290" cy="698" r="8" fill="#7b4ba3"/><text x="304" y="703">low / unknown</text><circle cx="424" cy="698" r="8" fill="none" stroke="#17242b" stroke-dasharray="4 2"/><text x="438" y="703">approximate / inferred</text></g><text x="32" y="738" class="attribution">${escapeXml(attribution)}</text></svg>`;
235
+ }
236
+
237
+ let puppeteerModule: typeof Puppeteer | undefined;
238
+ async function loadPuppeteer(): Promise<typeof Puppeteer> {
239
+ if (puppeteerModule) return puppeteerModule;
240
+ const safeDir = getPuppeteerDir();
241
+ await fs.mkdir(safeDir, { recursive: true });
242
+ await Bun.write(path.join(safeDir, "package.json"), "{}");
243
+ const previous = process.cwd();
244
+ try {
245
+ process.chdir(safeDir);
246
+ puppeteerModule = (await import("puppeteer")).default;
247
+ return puppeteerModule;
248
+ } finally {
249
+ process.chdir(previous);
250
+ }
251
+ }
252
+
253
+ export async function rasterizeMapSvg(
254
+ svg: string,
255
+ settings?: { get(key: string): unknown },
256
+ signal?: AbortSignal,
257
+ ): Promise<Buffer> {
258
+ const chrome = locateChrome({ settings });
259
+ if (!chrome) throw new Error("Chrome is unavailable for network-isolated map rasterization");
260
+ const puppeteer = await loadPuppeteer();
261
+ let browser: Browser | undefined;
262
+ try {
263
+ browser = await puppeteer.launch({
264
+ headless: true,
265
+ executablePath: chrome.path,
266
+ args: ["--disable-background-networking", "--disable-component-update", "--no-first-run"],
267
+ });
268
+ const page: Page = await browser.newPage();
269
+ await page.setJavaScriptEnabled(false);
270
+ await page.setRequestInterception(true);
271
+ page.on("request", request => {
272
+ if (request.url().startsWith("data:")) void request.continue();
273
+ else void request.abort("blockedbyclient");
274
+ });
275
+ await page.setViewport({ width: MAP_WIDTH, height: MAP_HEIGHT, deviceScaleFactor: 1 });
276
+ signal?.throwIfAborted();
277
+ await page.setContent(
278
+ `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;width:${MAP_WIDTH}px;height:${MAP_HEIGHT}px;overflow:hidden}</style></head><body>${svg}</body></html>`,
279
+ {
280
+ waitUntil: "domcontentloaded",
281
+ },
282
+ );
283
+ signal?.throwIfAborted();
284
+ return Buffer.from(
285
+ await page.screenshot({ type: "png", clip: { x: 0, y: 0, width: MAP_WIDTH, height: MAP_HEIGHT } }),
286
+ );
287
+ } finally {
288
+ await browser?.close();
289
+ }
290
+ }
291
+
292
+ export async function renderMap(
293
+ request: RenderMapRequest,
294
+ dependencies: RenderMapDependencies = {},
295
+ ): Promise<RenderedMap> {
296
+ const fit = fitMapLocations(request.locations);
297
+ const viewport = viewportFor(fit);
298
+ const rasterize = dependencies.rasterize ?? rasterizeMapSvg;
299
+ let basemap = request.basemap;
300
+ let degradation: string | undefined;
301
+ let tiles: TileImage[] = [];
302
+ if (basemap === "osm") {
303
+ try {
304
+ const fetcher =
305
+ dependencies.tileFetcher ?? new OsmTileFetcher({ cacheDir: path.join(getPuppeteerDir(), "osm-tiles") });
306
+ tiles = await loadTiles(viewport, fetcher, request.signal);
307
+ } catch (error) {
308
+ basemap = "schematic";
309
+ degradation = `OpenStreetMap tiles were unavailable; rendered a schematic map (${error instanceof Error ? error.message : String(error)}).`;
310
+ }
311
+ }
312
+ const svg = buildMapSvg(request.title, request.locations, basemap, tiles);
313
+ return { png: await rasterize(svg, request.settings, request.signal), basemap, degradation };
314
+ }
@@ -0,0 +1,130 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ export const OSM_TILE_USER_AGENT = "xcsh-map-renderer/1.0 (+https://github.com/f5-sales-demo/xcsh)";
5
+ const MAX_TILE_BYTES = 1024 * 1024;
6
+
7
+ interface TileMetadata {
8
+ etag?: string;
9
+ lastModified?: string;
10
+ expiresAt: number;
11
+ }
12
+
13
+ export interface TileRequest {
14
+ z: number;
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ export interface OsmTileFetcherOptions {
20
+ cacheDir: string;
21
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>;
22
+ now?: () => number;
23
+ }
24
+
25
+ function cacheLifetime(headers: Headers, now: number): number {
26
+ const cacheControl = headers.get("cache-control") ?? "";
27
+ if (/\bno-store\b/iu.test(cacheControl)) return 0;
28
+ const maxAge = /\bmax-age=(\d+)\b/iu.exec(cacheControl)?.[1];
29
+ if (maxAge) return Number(maxAge) * 1000;
30
+ const expires = headers.get("expires");
31
+ if (expires) return Math.max(0, Date.parse(expires) - now);
32
+ return 0;
33
+ }
34
+
35
+ function validateTile(data: Buffer): void {
36
+ if (data.byteLength === 0 || data.byteLength > MAX_TILE_BYTES) throw new Error("OSM tile size is invalid");
37
+ if (!data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
38
+ throw new Error("OSM tile is not a valid PNG");
39
+ }
40
+ }
41
+
42
+ async function readMetadata(file: string): Promise<TileMetadata | undefined> {
43
+ try {
44
+ const value = JSON.parse(await fs.readFile(file, "utf8")) as Partial<TileMetadata>;
45
+ if (!Number.isFinite(value.expiresAt)) return undefined;
46
+ return value as TileMetadata;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ export class OsmTileFetcher {
53
+ readonly #options: Required<Omit<OsmTileFetcherOptions, "cacheDir">> & Pick<OsmTileFetcherOptions, "cacheDir">;
54
+
55
+ constructor(options: OsmTileFetcherOptions) {
56
+ this.#options = {
57
+ cacheDir: options.cacheDir,
58
+ fetch: options.fetch ?? ((input, init) => fetch(input, init)),
59
+ now: options.now ?? Date.now,
60
+ };
61
+ }
62
+
63
+ async fetchTile(tile: TileRequest, signal?: AbortSignal): Promise<Buffer> {
64
+ if (!Number.isInteger(tile.z) || tile.z < 0 || tile.z > 19) throw new Error("invalid OSM tile zoom");
65
+ const count = 2 ** tile.z;
66
+ if (
67
+ !Number.isInteger(tile.x) ||
68
+ tile.x < 0 ||
69
+ tile.x >= count ||
70
+ !Number.isInteger(tile.y) ||
71
+ tile.y < 0 ||
72
+ tile.y >= count
73
+ ) {
74
+ throw new Error("invalid OSM tile coordinate");
75
+ }
76
+ const base = path.join(this.#options.cacheDir, String(tile.z), String(tile.x), String(tile.y));
77
+ const pngPath = `${base}.png`;
78
+ const metadataPath = `${base}.json`;
79
+ const now = this.#options.now();
80
+ const metadata = await readMetadata(metadataPath);
81
+ let cached: Buffer | undefined;
82
+ try {
83
+ cached = await fs.readFile(pngPath);
84
+ validateTile(cached);
85
+ } catch {
86
+ cached = undefined;
87
+ }
88
+ if (cached && metadata && metadata.expiresAt > now) return cached;
89
+
90
+ const headers = new Headers({
91
+ Accept: "image/png",
92
+ Referer: "https://github.com/f5-sales-demo/xcsh",
93
+ "User-Agent": OSM_TILE_USER_AGENT,
94
+ });
95
+ if (cached && metadata?.etag) headers.set("If-None-Match", metadata.etag);
96
+ if (cached && metadata?.lastModified) headers.set("If-Modified-Since", metadata.lastModified);
97
+ const response = await this.#options.fetch(`https://tile.openstreetmap.org/${tile.z}/${tile.x}/${tile.y}.png`, {
98
+ headers,
99
+ redirect: "error",
100
+ signal,
101
+ });
102
+ if (response.status === 304 && cached) {
103
+ await this.#writeMetadata(metadataPath, response.headers, now, metadata);
104
+ return cached;
105
+ }
106
+ if (!response.ok) throw new Error(`OSM tile request failed with HTTP ${response.status}`);
107
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim();
108
+ if (contentType !== "image/png") throw new Error("OSM tile response was not image/png");
109
+ const declaredLength = Number(response.headers.get("content-length"));
110
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_TILE_BYTES) throw new Error("OSM tile is too large");
111
+ const data = Buffer.from(await response.arrayBuffer());
112
+ validateTile(data);
113
+ if (!/\bno-store\b/iu.test(response.headers.get("cache-control") ?? "")) {
114
+ await fs.mkdir(path.dirname(pngPath), { recursive: true });
115
+ await Bun.write(pngPath, data);
116
+ await this.#writeMetadata(metadataPath, response.headers, now);
117
+ }
118
+ return data;
119
+ }
120
+
121
+ async #writeMetadata(file: string, headers: Headers, now: number, previous?: TileMetadata): Promise<void> {
122
+ const value: TileMetadata = {
123
+ etag: headers.get("etag") ?? previous?.etag,
124
+ lastModified: headers.get("last-modified") ?? previous?.lastModified,
125
+ expiresAt: now + cacheLifetime(headers, now),
126
+ };
127
+ await fs.mkdir(path.dirname(file), { recursive: true });
128
+ await Bun.write(file, `${JSON.stringify(value)}\n`);
129
+ }
130
+ }