@khorsheed/dsh-reader 0.2.1 → 0.3.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/lib/index.js CHANGED
@@ -1169,6 +1169,421 @@ function arxivHtmlUrl(url) {
1169
1169
  if (link === void 0 || link.kind === "html") return void 0;
1170
1170
  return `https://arxiv.org/html/${link.id}`;
1171
1171
  }
1172
+ /** An honest agent line — bot walls are reported, never impersonated around. */
1173
+ const IMAGE_FETCH_USER_AGENT = "dsh-reader image rescue (+https://github.com/Khorsheed/dsh-plugins)";
1174
+ /**
1175
+ * The raster/vector types a rescued image may declare. The pane re-points the
1176
+ * failed `<img>` at these bytes, so an SVG rides an image context (which never
1177
+ * scripts); anything else the origin answers — HTML above all — is refused
1178
+ * before the body is read.
1179
+ */
1180
+ const IMAGE_MIME = /^image\/(?:png|jpe?g|gif|webp|avif|svg\+xml|bmp|x-icon|vnd\.microsoft\.icon)$/i;
1181
+ /**
1182
+ * The byte budget of the in-memory rescue cache (wire size — base64 is what
1183
+ * the map actually holds). A figure-heavy article is a few MB; the budget
1184
+ * covers several of them, and the oldest entry leaves first.
1185
+ */
1186
+ const IMAGE_CACHE_BUDGET_BYTES = 33554432;
1187
+ /**
1188
+ * The rescue cache: a pane remount (the sidebar unmounts wholesale when
1189
+ * another panel takes over) re-fails every CORP-blocked image, and this map is
1190
+ * what keeps that a memory read instead of a dozen outbound requests. LRU by
1191
+ * insertion refresh, bounded by bytes.
1192
+ */
1193
+ var ImageFetchCache = class {
1194
+ budgetBytes;
1195
+ entries = /* @__PURE__ */ new Map();
1196
+ bytes = 0;
1197
+ constructor(budgetBytes = IMAGE_CACHE_BUDGET_BYTES) {
1198
+ this.budgetBytes = budgetBytes;
1199
+ }
1200
+ /** The cached bytes for one URL, refreshing its recency. */
1201
+ get(url) {
1202
+ const hit = this.entries.get(url);
1203
+ if (hit === void 0) return void 0;
1204
+ this.entries.delete(url);
1205
+ this.entries.set(url, hit);
1206
+ return hit;
1207
+ }
1208
+ /** Cache one fetched image, evicting the oldest entries until it fits. */
1209
+ set(url, mime, base64) {
1210
+ if (base64.length > this.budgetBytes) return;
1211
+ const existing = this.entries.get(url);
1212
+ if (existing !== void 0) {
1213
+ this.bytes -= existing.base64.length;
1214
+ this.entries.delete(url);
1215
+ }
1216
+ while (this.bytes + base64.length > this.budgetBytes) {
1217
+ const oldest = this.entries.keys().next();
1218
+ if (oldest.done) break;
1219
+ const dropped = this.entries.get(oldest.value);
1220
+ this.entries.delete(oldest.value);
1221
+ this.bytes -= dropped?.base64.length ?? 0;
1222
+ }
1223
+ this.entries.set(url, {
1224
+ mime,
1225
+ base64
1226
+ });
1227
+ this.bytes += base64.length;
1228
+ }
1229
+ };
1230
+ /**
1231
+ * Fetch one image, policy-checked end to end.
1232
+ *
1233
+ * Errors are values: the pane's only answer to a failed rescue is leaving the
1234
+ * broken image as it was, so the reason string is for logs, not for display.
1235
+ *
1236
+ * @param rawUrl - the image URL the pane failed on.
1237
+ * @param seams - resolver/lookup overrides (tests only; production pins).
1238
+ * @param limits - byte/timeout/redirect overrides (tests only).
1239
+ * @returns the image bytes base64-encoded with their declared type, or why not.
1240
+ */
1241
+ async function fetchImageBytes(rawUrl, seams = {}, limits = {}) {
1242
+ const maxBytes = limits.maxBytes ?? 8388608;
1243
+ const timeoutMs = limits.timeoutMs ?? 15e3;
1244
+ const maxRedirects = limits.maxRedirects ?? 3;
1245
+ let current = rawUrl;
1246
+ for (let hop = 0; hop <= maxRedirects; hop++) {
1247
+ const gate = await checkImageTarget(current, seams.resolve);
1248
+ if (!gate.ok) return {
1249
+ ok: false,
1250
+ url: rawUrl,
1251
+ error: gate.error
1252
+ };
1253
+ const response = await requestOnce(gate.url, gate.addresses, timeoutMs, seams.lookup);
1254
+ if (!response.ok) return {
1255
+ ok: false,
1256
+ url: rawUrl,
1257
+ error: response.error
1258
+ };
1259
+ const { message } = response;
1260
+ const status = message.statusCode ?? 0;
1261
+ if (status >= 300 && status < 400) {
1262
+ message.resume();
1263
+ const location = message.headers["location"];
1264
+ if (typeof location !== "string" || location.length === 0) return {
1265
+ ok: false,
1266
+ url: rawUrl,
1267
+ error: `HTTP ${status} without a location`
1268
+ };
1269
+ try {
1270
+ current = new URL(location, current).toString();
1271
+ } catch {
1272
+ return {
1273
+ ok: false,
1274
+ url: rawUrl,
1275
+ error: `HTTP ${status} to an unparseable location`
1276
+ };
1277
+ }
1278
+ continue;
1279
+ }
1280
+ if (status < 200 || status >= 300) {
1281
+ message.resume();
1282
+ return {
1283
+ ok: false,
1284
+ url: rawUrl,
1285
+ error: `HTTP ${status}`
1286
+ };
1287
+ }
1288
+ const mime = (message.headers["content-type"] ?? "").replace(/;.*$/s, "").trim().toLowerCase();
1289
+ if (!IMAGE_MIME.test(mime)) {
1290
+ message.resume();
1291
+ return {
1292
+ ok: false,
1293
+ url: rawUrl,
1294
+ error: `the response is not an image (content-type "${mime || "unknown"}")`
1295
+ };
1296
+ }
1297
+ const declared = Number(message.headers["content-length"] ?? NaN);
1298
+ if (Number.isFinite(declared) && declared > maxBytes) {
1299
+ message.resume();
1300
+ return {
1301
+ ok: false,
1302
+ url: rawUrl,
1303
+ error: `the image exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MiB cap`
1304
+ };
1305
+ }
1306
+ const body = await readBody(message, maxBytes);
1307
+ if (!body.ok) return {
1308
+ ok: false,
1309
+ url: rawUrl,
1310
+ error: body.error
1311
+ };
1312
+ return {
1313
+ ok: true,
1314
+ url: rawUrl,
1315
+ mime,
1316
+ base64: body.base64
1317
+ };
1318
+ }
1319
+ return {
1320
+ ok: false,
1321
+ url: rawUrl,
1322
+ error: `more than ${maxRedirects} redirects`
1323
+ };
1324
+ }
1325
+ /**
1326
+ * The syntax gate plus the address gate, in the order capture's navigation
1327
+ * policy runs them. The address set travels with the verdict: the transport
1328
+ * dials exactly these, so the hostname is never resolved twice.
1329
+ */
1330
+ async function checkImageTarget(raw, resolve) {
1331
+ let url;
1332
+ try {
1333
+ url = new URL(raw);
1334
+ } catch {
1335
+ return {
1336
+ ok: false,
1337
+ error: "invalid-url"
1338
+ };
1339
+ }
1340
+ if (url.protocol !== "http:" && url.protocol !== "https:") return {
1341
+ ok: false,
1342
+ error: `only http(s) images can be rescued, not ${url.protocol}`
1343
+ };
1344
+ if (url.username !== "" || url.password !== "") return {
1345
+ ok: false,
1346
+ error: "URLs with embedded credentials are never fetched"
1347
+ };
1348
+ const hostname = url.hostname.replace(/\.$/, "");
1349
+ if (hostname === "") return {
1350
+ ok: false,
1351
+ error: "invalid-url"
1352
+ };
1353
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) return {
1354
+ ok: false,
1355
+ error: "localhost is never fetched"
1356
+ };
1357
+ if (isIpLiteral(hostname)) {
1358
+ const bare = hostname.startsWith("[") ? hostname.slice(1, -1) : hostname;
1359
+ const cls = classifyAddress(bare);
1360
+ if (cls !== "public") return {
1361
+ ok: false,
1362
+ error: `${bare} is a ${cls} address`
1363
+ };
1364
+ return {
1365
+ ok: true,
1366
+ url,
1367
+ addresses: [{
1368
+ address: bare,
1369
+ family: bare.includes(":") ? 6 : 4
1370
+ }]
1371
+ };
1372
+ }
1373
+ const resolver = resolve ?? defaultResolve;
1374
+ let addresses;
1375
+ try {
1376
+ addresses = await resolver(hostname.replace(/^\[(.*)\]$/, "$1"));
1377
+ } catch (error) {
1378
+ return {
1379
+ ok: false,
1380
+ error: `cannot resolve ${hostname}: ${error instanceof Error ? error.message : String(error)}`
1381
+ };
1382
+ }
1383
+ if (addresses.length === 0) return {
1384
+ ok: false,
1385
+ error: `cannot resolve ${hostname}: no addresses`
1386
+ };
1387
+ for (const { address } of addresses) {
1388
+ const cls = classifyAddress(address);
1389
+ if (cls !== "public") return {
1390
+ ok: false,
1391
+ error: `${hostname} resolves to a ${cls} address`
1392
+ };
1393
+ }
1394
+ return {
1395
+ ok: true,
1396
+ url,
1397
+ addresses
1398
+ };
1399
+ }
1400
+ /**
1401
+ * Issue one GET against the validated address set.
1402
+ *
1403
+ * The `lookup` handed to the transport answers with the validated addresses
1404
+ * and nothing else — DNS is consulted once, by the gate, and the connection
1405
+ * cannot re-resolve the hostname into a private network (the rebinding window
1406
+ * a plain fetch leaves open). Node still verifies TLS against the HOSTNAME;
1407
+ * the lookup only maps it to an address.
1408
+ */
1409
+ function requestOnce(url, addresses, timeoutMs, lookup) {
1410
+ return new Promise((resolvePromise) => {
1411
+ (async () => {
1412
+ const request = (url.protocol === "https:" ? await import("node:https") : await import("node:http")).request({
1413
+ protocol: url.protocol,
1414
+ hostname: url.hostname.replace(/^\[(.*)\]$/, "$1"),
1415
+ port: url.port,
1416
+ path: `${url.pathname}${url.search}`,
1417
+ method: "GET",
1418
+ headers: {
1419
+ accept: "image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8",
1420
+ "accept-encoding": "identity",
1421
+ "user-agent": IMAGE_FETCH_USER_AGENT
1422
+ },
1423
+ lookup: lookup ?? pinnedLookup(addresses)
1424
+ }, (message) => resolvePromise({
1425
+ ok: true,
1426
+ message
1427
+ }));
1428
+ request.setTimeout(timeoutMs, () => {
1429
+ request.destroy(/* @__PURE__ */ new Error(`the image fetch timed out after ${timeoutMs} ms`));
1430
+ });
1431
+ request.on("error", (error) => resolvePromise({
1432
+ ok: false,
1433
+ error: error.message
1434
+ }));
1435
+ request.end();
1436
+ })().catch((error) => resolvePromise({
1437
+ ok: false,
1438
+ error: error instanceof Error ? error.message : String(error)
1439
+ }));
1440
+ });
1441
+ }
1442
+ /** The lookup that pins a request to the gate's validated answers. */
1443
+ function pinnedLookup(addresses) {
1444
+ return (_hostname, options, callback) => {
1445
+ if (options.all === true) {
1446
+ callback(null, addresses.map(({ address, family }) => ({
1447
+ address,
1448
+ family
1449
+ })));
1450
+ return;
1451
+ }
1452
+ const first = addresses[0];
1453
+ callback(null, first.address, first.family);
1454
+ };
1455
+ }
1456
+ /** The body, capped; over the cap is a refusal, not a truncation. */
1457
+ function readBody(message, maxBytes) {
1458
+ return new Promise((resolvePromise) => {
1459
+ const chunks = [];
1460
+ let received = 0;
1461
+ message.on("data", (chunk) => {
1462
+ received += chunk.byteLength;
1463
+ if (received > maxBytes) {
1464
+ message.destroy();
1465
+ resolvePromise({
1466
+ ok: false,
1467
+ error: `the image exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MiB cap`
1468
+ });
1469
+ return;
1470
+ }
1471
+ chunks.push(chunk);
1472
+ });
1473
+ message.on("end", () => resolvePromise({
1474
+ ok: true,
1475
+ base64: Buffer.concat(chunks).toString("base64")
1476
+ }));
1477
+ message.on("error", (error) => resolvePromise({
1478
+ ok: false,
1479
+ error: error.message
1480
+ }));
1481
+ });
1482
+ }
1483
+ /** The system resolver, bound lazily so importing this module never touches `node:dns`. */
1484
+ async function defaultResolve(hostname) {
1485
+ return (await import("node:dns")).promises.lookup(hostname, {
1486
+ all: true,
1487
+ verbatim: true
1488
+ });
1489
+ }
1490
+ /** Parse a canonical IPv4 string into its 32-bit value, or undefined. */
1491
+ function parseIpv4(address) {
1492
+ const parts = address.split(".");
1493
+ if (parts.length !== 4) return void 0;
1494
+ let value = 0;
1495
+ for (const part of parts) {
1496
+ if (!/^\d{1,3}$/.test(part)) return void 0;
1497
+ if (part.length > 1 && part.startsWith("0")) return void 0;
1498
+ const octet = Number(part);
1499
+ if (octet > 255) return void 0;
1500
+ value = value * 256 + octet;
1501
+ }
1502
+ return value >>> 0;
1503
+ }
1504
+ /** Expand an IPv6 string into its eight 16-bit groups, or undefined when malformed. */
1505
+ function parseIpv6Groups(address) {
1506
+ const zoneless = address.split("%")[0];
1507
+ if (!/^[0-9a-fA-F:.]+$/.test(zoneless)) return void 0;
1508
+ const halves = zoneless.split("::");
1509
+ if (halves.length > 2) return void 0;
1510
+ const parseHalf = (half) => {
1511
+ if (half === "") return [];
1512
+ const groups = [];
1513
+ const parts = half.split(":");
1514
+ for (const [index, part] of parts.entries()) {
1515
+ if (part.includes(".")) {
1516
+ if (index !== parts.length - 1) return void 0;
1517
+ const v4 = parseIpv4(part);
1518
+ if (v4 === void 0) return void 0;
1519
+ groups.push(v4 >>> 16 & 65535, v4 & 65535);
1520
+ continue;
1521
+ }
1522
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return void 0;
1523
+ groups.push(parseInt(part, 16));
1524
+ }
1525
+ return groups;
1526
+ };
1527
+ const head = parseHalf(halves[0]);
1528
+ if (head === void 0) return void 0;
1529
+ if (halves.length === 1) return head.length === 8 ? head : void 0;
1530
+ const tail = parseHalf(halves[1]);
1531
+ if (tail === void 0) return void 0;
1532
+ const missing = 8 - head.length - tail.length;
1533
+ if (missing < 0 || missing === 0 && halves[1] !== "") return void 0;
1534
+ return [
1535
+ ...head,
1536
+ ...Array(missing).fill(0),
1537
+ ...tail
1538
+ ];
1539
+ }
1540
+ /** The embedded IPv4 value of an IPv4-mapped, NAT64, or 6to4 IPv6 address. */
1541
+ function embeddedIpv4(groups) {
1542
+ if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 65535) return (groups[6] << 16 | groups[7]) >>> 0;
1543
+ if (groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0)) return (groups[6] << 16 | groups[7]) >>> 0;
1544
+ if (groups[0] === 8194) return (groups[1] << 16 | groups[2]) >>> 0;
1545
+ }
1546
+ /** Classify a canonical dotted-quad IPv4 value. */
1547
+ function classifyIpv4(value) {
1548
+ const a = value >>> 24 & 255;
1549
+ const b = value >>> 16 & 255;
1550
+ if (a === 0) return "unspecified";
1551
+ if (a === 127) return "loopback";
1552
+ if (a === 10) return "private";
1553
+ if (a === 172 && b >= 16 && b <= 31) return "private";
1554
+ if (a === 192 && b === 168) return "private";
1555
+ if (a === 169 && b === 254) return "link-local";
1556
+ if (a === 100 && b >= 64 && b <= 127) return "private";
1557
+ if (a === 192 && b === 0) return "reserved";
1558
+ if (a === 198 && (b === 18 || b === 19)) return "reserved";
1559
+ if (a === 198 && b === 51) return "reserved";
1560
+ if (a === 203 && b === 0) return "reserved";
1561
+ if (a >= 224 && a <= 239) return "multicast";
1562
+ if (a >= 240) return "reserved";
1563
+ return "public";
1564
+ }
1565
+ /** Classify one resolved address (IPv4 or IPv6, canonical or transition form). */
1566
+ function classifyAddress(address) {
1567
+ const v4 = parseIpv4(address);
1568
+ if (v4 !== void 0) return classifyIpv4(v4);
1569
+ const groups = parseIpv6Groups(address);
1570
+ if (groups === void 0) return "reserved";
1571
+ const embedded = embeddedIpv4(groups);
1572
+ if (embedded !== void 0) return classifyIpv4(embedded);
1573
+ const first = groups[0];
1574
+ if (groups.every((g) => g === 0)) return "unspecified";
1575
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return "loopback";
1576
+ if ((first & 65024) === 64512) return "private";
1577
+ if ((first & 65472) === 65152) return "link-local";
1578
+ if ((first & 65280) === 65280) return "multicast";
1579
+ if (first === 8193 && groups[1] === 3512) return "reserved";
1580
+ return "public";
1581
+ }
1582
+ /** Whether one hostname string is already an IP literal (bracketed v6 or dotted v4). */
1583
+ function isIpLiteral(hostname) {
1584
+ const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1585
+ return parseIpv4(bare) !== void 0 || parseIpv6Groups(bare) !== void 0;
1586
+ }
1172
1587
  //#endregion
1173
1588
  //#region lib/types/link-resolvers.js
1174
1589
  /**
@@ -1469,6 +1884,8 @@ var ReaderService = class {
1469
1884
  disposed = false;
1470
1885
  /** The document of record when no filesystem is mounted. */
1471
1886
  memoryDoc;
1887
+ /** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
1888
+ imageCache = new ImageFetchCache();
1472
1889
  /**
1473
1890
  * @param ctx - owning context.
1474
1891
  * @param config - optional state-root override.
@@ -1930,6 +2347,54 @@ var ReaderService = class {
1930
2347
  }
1931
2348
  }
1932
2349
  /**
2350
+ * One image the browser could not load cross-origin, fetched host-side.
2351
+ *
2352
+ * The pane calls this only for an `<img>` that already FAILED: the page's
2353
+ * bytes arrived fine (a direct fetch gets a 200) but the origin answers them
2354
+ * with `Cross-Origin-Resource-Policy: same-origin`, so the browser withholds
2355
+ * them from this page no matter what the tag asks — measured on claude.dev's
2356
+ * `/media/*` figures (2026-09-29), which broke with the alt text showing.
2357
+ * CORP is an embedder-side check, so a host-process fetch is the way through.
2358
+ *
2359
+ * This deliberately does NOT ride `ctx.web.fetch`: the seam's decoded body is
2360
+ * a closed html|text union and its provider refuses binary content types
2361
+ * outright. The image fetch re-implements the seam's hardening at image
2362
+ * scope instead (public-address validation with the connection pinned to the
2363
+ * validated answers, a byte cap, a timeout, per-hop re-validation, and an
2364
+ * image/* content-type gate) — see `image-fetch.ts`.
2365
+ *
2366
+ * Rescued bytes are kept in an in-memory LRU under a byte budget: the
2367
+ * sidebar unmounts wholesale when another panel takes over, and a remount
2368
+ * re-fails every blocked image before this cache can answer it.
2369
+ *
2370
+ * @param request - the image URL the pane failed on.
2371
+ * @returns the bytes and their declared type, or why there are none.
2372
+ */
2373
+ async fetchImage(request) {
2374
+ const url = normalizeUrl(request.url);
2375
+ if (url === void 0) return {
2376
+ url: request.url,
2377
+ error: "invalid-url"
2378
+ };
2379
+ const cached = this.imageCache.get(url);
2380
+ if (cached !== void 0) return {
2381
+ url,
2382
+ mime: cached.mime,
2383
+ base64: cached.base64
2384
+ };
2385
+ const fetched = await fetchImageBytes(url);
2386
+ if (!fetched.ok) return {
2387
+ url,
2388
+ error: fetched.error
2389
+ };
2390
+ this.imageCache.set(url, fetched.mime, fetched.base64);
2391
+ return {
2392
+ url,
2393
+ mime: fetched.mime,
2394
+ base64: fetched.base64
2395
+ };
2396
+ }
2397
+ /**
1933
2398
  * Which entries have a body, a stored raw payload, or a recorded failure.
1934
2399
  *
1935
2400
  * The wall needs this per ENTRY (entries come from the browser's parse of a
@@ -3098,6 +3563,7 @@ let ReaderRemoteService = (() => {
3098
3563
  let _getBodies_decorators;
3099
3564
  let _getEntryBody_decorators;
3100
3565
  let _fetchEntryBody_decorators;
3566
+ let _fetchImage_decorators;
3101
3567
  let _storeEntryBody_decorators;
3102
3568
  let _entryFetchStates_decorators;
3103
3569
  let _getRawBody_decorators;
@@ -3132,6 +3598,7 @@ let ReaderRemoteService = (() => {
3132
3598
  _getBodies_decorators = [Remote("getBodies")];
3133
3599
  _getEntryBody_decorators = [Remote("getEntryBody")];
3134
3600
  _fetchEntryBody_decorators = [Remote("fetchEntryBody")];
3601
+ _fetchImage_decorators = [Remote("fetchImage")];
3135
3602
  _storeEntryBody_decorators = [Remote("storeEntryBody")];
3136
3603
  _entryFetchStates_decorators = [Remote("entryFetchStates")];
3137
3604
  _getRawBody_decorators = [Remote("getRawBody")];
@@ -3253,6 +3720,17 @@ let ReaderRemoteService = (() => {
3253
3720
  },
3254
3721
  metadata: _metadata
3255
3722
  }, null, _instanceExtraInitializers);
3723
+ __esDecorate(this, null, _fetchImage_decorators, {
3724
+ kind: "method",
3725
+ name: "fetchImage",
3726
+ static: false,
3727
+ private: false,
3728
+ access: {
3729
+ has: (obj) => "fetchImage" in obj,
3730
+ get: (obj) => obj.fetchImage
3731
+ },
3732
+ metadata: _metadata
3733
+ }, null, _instanceExtraInitializers);
3256
3734
  __esDecorate(this, null, _storeEntryBody_decorators, {
3257
3735
  kind: "method",
3258
3736
  name: "storeEntryBody",
@@ -3547,6 +4025,14 @@ let ReaderRemoteService = (() => {
3547
4025
  fetchEntryBody(request) {
3548
4026
  return this.core.fetchEntryBody(request);
3549
4027
  }
4028
+ /**
4029
+ * Fetch one image the browser could not load cross-origin (CORP /
4030
+ * hotlink-protected), so the pane can re-point the failed `<img>` at the
4031
+ * bytes. Called per failed image, never for a healthy one.
4032
+ */
4033
+ fetchImage(request) {
4034
+ return this.core.fetchImage(request);
4035
+ }
3550
4036
  /** Cache the markup the browser extracted for one entry. */
3551
4037
  storeEntryBody(request) {
3552
4038
  return this.core.storeEntryBody(request);