@khorsheed/dsh-reader 0.2.0 → 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/CHANGELOG.md +14 -0
- package/README.en.md +108 -54
- package/README.i18n.yaml +2 -2
- package/README.md +108 -54
- package/lib/client.js +2703 -1371
- package/lib/index.js +486 -0
- package/lib/typert.host.js +91 -31
- package/lib/typert.remote-client.d.ts +3 -1
- package/lib/typert.remote-client.js +69 -31
- package/lib/types/client/ReaderPane.js +46 -1
- package/lib/types/client/contract.d.ts +9 -1
- package/lib/types/client/index.js +1 -0
- package/lib/types/image-fetch.d.ts +81 -0
- package/lib/types/image-fetch.js +385 -0
- package/lib/types/remote.d.ts +9 -1
- package/lib/types/remote.js +11 -0
- package/lib/types/service.d.ts +30 -1
- package/lib/types/service.js +40 -0
- package/lib/types/types.d.ts +19 -0
- package/package.json +12 -11
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);
|