@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.
@@ -0,0 +1,385 @@
1
+ /** An article figure is a few hundred KB; 8 MiB leaves room for print-resolution PNGs. */
2
+ export const IMAGE_FETCH_MAX_BYTES = 8 * 1024 * 1024;
3
+ /** One image fetch never hangs the rescue: the pane's picture stays broken instead. */
4
+ export const IMAGE_FETCH_TIMEOUT_MS = 15_000;
5
+ /** Redirect hops per image, each re-validated end to end (the classic SSRF hop). */
6
+ export const IMAGE_FETCH_MAX_REDIRECTS = 3;
7
+ /** An honest agent line — bot walls are reported, never impersonated around. */
8
+ const IMAGE_FETCH_USER_AGENT = 'dsh-reader image rescue (+https://github.com/Khorsheed/dsh-plugins)';
9
+ /**
10
+ * The raster/vector types a rescued image may declare. The pane re-points the
11
+ * failed `<img>` at these bytes, so an SVG rides an image context (which never
12
+ * scripts); anything else the origin answers — HTML above all — is refused
13
+ * before the body is read.
14
+ */
15
+ const IMAGE_MIME = /^image\/(?:png|jpe?g|gif|webp|avif|svg\+xml|bmp|x-icon|vnd\.microsoft\.icon)$/i;
16
+ /**
17
+ * The byte budget of the in-memory rescue cache (wire size — base64 is what
18
+ * the map actually holds). A figure-heavy article is a few MB; the budget
19
+ * covers several of them, and the oldest entry leaves first.
20
+ */
21
+ export const IMAGE_CACHE_BUDGET_BYTES = 32 * 1024 * 1024;
22
+ /**
23
+ * The rescue cache: a pane remount (the sidebar unmounts wholesale when
24
+ * another panel takes over) re-fails every CORP-blocked image, and this map is
25
+ * what keeps that a memory read instead of a dozen outbound requests. LRU by
26
+ * insertion refresh, bounded by bytes.
27
+ */
28
+ export class ImageFetchCache {
29
+ budgetBytes;
30
+ entries = new Map();
31
+ bytes = 0;
32
+ constructor(budgetBytes = IMAGE_CACHE_BUDGET_BYTES) {
33
+ this.budgetBytes = budgetBytes;
34
+ }
35
+ /** The cached bytes for one URL, refreshing its recency. */
36
+ get(url) {
37
+ const hit = this.entries.get(url);
38
+ if (hit === undefined)
39
+ return undefined;
40
+ this.entries.delete(url);
41
+ this.entries.set(url, hit);
42
+ return hit;
43
+ }
44
+ /** Cache one fetched image, evicting the oldest entries until it fits. */
45
+ set(url, mime, base64) {
46
+ if (base64.length > this.budgetBytes)
47
+ return;
48
+ const existing = this.entries.get(url);
49
+ if (existing !== undefined) {
50
+ this.bytes -= existing.base64.length;
51
+ this.entries.delete(url);
52
+ }
53
+ while (this.bytes + base64.length > this.budgetBytes) {
54
+ const oldest = this.entries.keys().next();
55
+ if (oldest.done)
56
+ break;
57
+ const dropped = this.entries.get(oldest.value);
58
+ this.entries.delete(oldest.value);
59
+ this.bytes -= dropped?.base64.length ?? 0;
60
+ }
61
+ this.entries.set(url, { mime, base64 });
62
+ this.bytes += base64.length;
63
+ }
64
+ }
65
+ /**
66
+ * Fetch one image, policy-checked end to end.
67
+ *
68
+ * Errors are values: the pane's only answer to a failed rescue is leaving the
69
+ * broken image as it was, so the reason string is for logs, not for display.
70
+ *
71
+ * @param rawUrl - the image URL the pane failed on.
72
+ * @param seams - resolver/lookup overrides (tests only; production pins).
73
+ * @param limits - byte/timeout/redirect overrides (tests only).
74
+ * @returns the image bytes base64-encoded with their declared type, or why not.
75
+ */
76
+ export async function fetchImageBytes(rawUrl, seams = {}, limits = {}) {
77
+ const maxBytes = limits.maxBytes ?? IMAGE_FETCH_MAX_BYTES;
78
+ const timeoutMs = limits.timeoutMs ?? IMAGE_FETCH_TIMEOUT_MS;
79
+ const maxRedirects = limits.maxRedirects ?? IMAGE_FETCH_MAX_REDIRECTS;
80
+ let current = rawUrl;
81
+ for (let hop = 0; hop <= maxRedirects; hop++) {
82
+ const gate = await checkImageTarget(current, seams.resolve);
83
+ if (!gate.ok)
84
+ return { ok: false, url: rawUrl, error: gate.error };
85
+ const response = await requestOnce(gate.url, gate.addresses, timeoutMs, seams.lookup);
86
+ if (!response.ok)
87
+ return { ok: false, url: rawUrl, error: response.error };
88
+ const { message } = response;
89
+ const status = message.statusCode ?? 0;
90
+ if (status >= 300 && status < 400) {
91
+ message.resume();
92
+ const location = message.headers['location'];
93
+ if (typeof location !== 'string' || location.length === 0) {
94
+ return { ok: false, url: rawUrl, error: `HTTP ${status} without a location` };
95
+ }
96
+ try {
97
+ current = new URL(location, current).toString();
98
+ }
99
+ catch {
100
+ return { ok: false, url: rawUrl, error: `HTTP ${status} to an unparseable location` };
101
+ }
102
+ continue;
103
+ }
104
+ if (status < 200 || status >= 300) {
105
+ message.resume();
106
+ return { ok: false, url: rawUrl, error: `HTTP ${status}` };
107
+ }
108
+ const mime = (message.headers['content-type'] ?? '').replace(/;.*$/s, '').trim().toLowerCase();
109
+ if (!IMAGE_MIME.test(mime)) {
110
+ message.resume();
111
+ return { ok: false, url: rawUrl, error: `the response is not an image (content-type "${mime || 'unknown'}")` };
112
+ }
113
+ const declared = Number(message.headers['content-length'] ?? Number.NaN);
114
+ if (Number.isFinite(declared) && declared > maxBytes) {
115
+ message.resume();
116
+ return { ok: false, url: rawUrl, error: `the image exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MiB cap` };
117
+ }
118
+ const body = await readBody(message, maxBytes);
119
+ if (!body.ok)
120
+ return { ok: false, url: rawUrl, error: body.error };
121
+ return { ok: true, url: rawUrl, mime, base64: body.base64 };
122
+ }
123
+ return { ok: false, url: rawUrl, error: `more than ${maxRedirects} redirects` };
124
+ }
125
+ /**
126
+ * The syntax gate plus the address gate, in the order capture's navigation
127
+ * policy runs them. The address set travels with the verdict: the transport
128
+ * dials exactly these, so the hostname is never resolved twice.
129
+ */
130
+ async function checkImageTarget(raw, resolve) {
131
+ let url;
132
+ try {
133
+ url = new URL(raw);
134
+ }
135
+ catch {
136
+ return { ok: false, error: 'invalid-url' };
137
+ }
138
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
139
+ return { ok: false, error: `only http(s) images can be rescued, not ${url.protocol}` };
140
+ }
141
+ if (url.username !== '' || url.password !== '') {
142
+ return { ok: false, error: 'URLs with embedded credentials are never fetched' };
143
+ }
144
+ const hostname = url.hostname.replace(/\.$/, '');
145
+ if (hostname === '')
146
+ return { ok: false, error: 'invalid-url' };
147
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
148
+ return { ok: false, error: 'localhost is never fetched' };
149
+ }
150
+ if (isIpLiteral(hostname)) {
151
+ const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
152
+ const cls = classifyAddress(bare);
153
+ if (cls !== 'public')
154
+ return { ok: false, error: `${bare} is a ${cls} address` };
155
+ return { ok: true, url, addresses: [{ address: bare, family: bare.includes(':') ? 6 : 4 }] };
156
+ }
157
+ const resolver = resolve ?? defaultResolve;
158
+ let addresses;
159
+ try {
160
+ addresses = await resolver(hostname.replace(/^\[(.*)\]$/, '$1'));
161
+ }
162
+ catch (error) {
163
+ return { ok: false, error: `cannot resolve ${hostname}: ${error instanceof Error ? error.message : String(error)}` };
164
+ }
165
+ if (addresses.length === 0)
166
+ return { ok: false, error: `cannot resolve ${hostname}: no addresses` };
167
+ for (const { address } of addresses) {
168
+ const cls = classifyAddress(address);
169
+ if (cls !== 'public')
170
+ return { ok: false, error: `${hostname} resolves to a ${cls} address` };
171
+ }
172
+ return { ok: true, url, addresses };
173
+ }
174
+ /**
175
+ * Issue one GET against the validated address set.
176
+ *
177
+ * The `lookup` handed to the transport answers with the validated addresses
178
+ * and nothing else — DNS is consulted once, by the gate, and the connection
179
+ * cannot re-resolve the hostname into a private network (the rebinding window
180
+ * a plain fetch leaves open). Node still verifies TLS against the HOSTNAME;
181
+ * the lookup only maps it to an address.
182
+ */
183
+ function requestOnce(url, addresses, timeoutMs, lookup) {
184
+ return new Promise((resolvePromise) => {
185
+ void (async () => {
186
+ const transport = url.protocol === 'https:'
187
+ ? await import('node:https')
188
+ : await import('node:http');
189
+ const request = transport.request({
190
+ protocol: url.protocol,
191
+ // Bracketed IPv6 literals are a URL spelling, not a host option.
192
+ hostname: url.hostname.replace(/^\[(.*)\]$/, '$1'),
193
+ port: url.port,
194
+ path: `${url.pathname}${url.search}`,
195
+ method: 'GET',
196
+ headers: {
197
+ accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8',
198
+ 'accept-encoding': 'identity',
199
+ 'user-agent': IMAGE_FETCH_USER_AGENT,
200
+ },
201
+ lookup: lookup ?? pinnedLookup(addresses),
202
+ }, (message) => resolvePromise({ ok: true, message }));
203
+ request.setTimeout(timeoutMs, () => {
204
+ request.destroy(new Error(`the image fetch timed out after ${timeoutMs} ms`));
205
+ });
206
+ request.on('error', (error) => resolvePromise({ ok: false, error: error.message }));
207
+ request.end();
208
+ })().catch((error) => resolvePromise({ ok: false, error: error instanceof Error ? error.message : String(error) }));
209
+ });
210
+ }
211
+ /** The lookup that pins a request to the gate's validated answers. */
212
+ function pinnedLookup(addresses) {
213
+ return (_hostname, options, callback) => {
214
+ if (options.all === true) {
215
+ callback(null, addresses.map(({ address, family }) => ({ address, family })));
216
+ return;
217
+ }
218
+ const first = addresses[0];
219
+ callback(null, first.address, first.family);
220
+ };
221
+ }
222
+ /** The body, capped; over the cap is a refusal, not a truncation. */
223
+ function readBody(message, maxBytes) {
224
+ return new Promise((resolvePromise) => {
225
+ const chunks = [];
226
+ let received = 0;
227
+ message.on('data', (chunk) => {
228
+ received += chunk.byteLength;
229
+ if (received > maxBytes) {
230
+ message.destroy();
231
+ resolvePromise({ ok: false, error: `the image exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MiB cap` });
232
+ return;
233
+ }
234
+ chunks.push(chunk);
235
+ });
236
+ message.on('end', () => resolvePromise({ ok: true, base64: Buffer.concat(chunks).toString('base64') }));
237
+ message.on('error', (error) => resolvePromise({ ok: false, error: error.message }));
238
+ });
239
+ }
240
+ /** The system resolver, bound lazily so importing this module never touches `node:dns`. */
241
+ async function defaultResolve(hostname) {
242
+ const dns = await import('node:dns');
243
+ return dns.promises.lookup(hostname, { all: true, verbatim: true });
244
+ }
245
+ /** Parse a canonical IPv4 string into its 32-bit value, or undefined. */
246
+ function parseIpv4(address) {
247
+ const parts = address.split('.');
248
+ if (parts.length !== 4)
249
+ return undefined;
250
+ let value = 0;
251
+ for (const part of parts) {
252
+ if (!/^\d{1,3}$/.test(part))
253
+ return undefined;
254
+ if (part.length > 1 && part.startsWith('0'))
255
+ return undefined; // legacy octal-looking octets: fail closed
256
+ const octet = Number(part);
257
+ if (octet > 255)
258
+ return undefined;
259
+ value = value * 256 + octet;
260
+ }
261
+ return value >>> 0;
262
+ }
263
+ /** Expand an IPv6 string into its eight 16-bit groups, or undefined when malformed. */
264
+ function parseIpv6Groups(address) {
265
+ // Strip a zone id (link-local literals like fe80::1%eth0).
266
+ const zoneless = address.split('%')[0];
267
+ if (!/^[0-9a-fA-F:.]+$/.test(zoneless))
268
+ return undefined;
269
+ const halves = zoneless.split('::');
270
+ if (halves.length > 2)
271
+ return undefined;
272
+ const parseHalf = (half) => {
273
+ if (half === '')
274
+ return [];
275
+ const groups = [];
276
+ const parts = half.split(':');
277
+ for (const [index, part] of parts.entries()) {
278
+ // An embedded dotted-quad tail (deprecated compatible / mapped forms).
279
+ if (part.includes('.')) {
280
+ if (index !== parts.length - 1)
281
+ return undefined;
282
+ const v4 = parseIpv4(part);
283
+ if (v4 === undefined)
284
+ return undefined;
285
+ groups.push((v4 >>> 16) & 0xffff, v4 & 0xffff);
286
+ continue;
287
+ }
288
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
289
+ return undefined;
290
+ groups.push(parseInt(part, 16));
291
+ }
292
+ return groups;
293
+ };
294
+ const head = parseHalf(halves[0]);
295
+ if (head === undefined)
296
+ return undefined;
297
+ if (halves.length === 1)
298
+ return head.length === 8 ? head : undefined;
299
+ const tail = parseHalf(halves[1]);
300
+ if (tail === undefined)
301
+ return undefined;
302
+ const missing = 8 - head.length - tail.length;
303
+ if (missing < 0 || (missing === 0 && halves[1] !== ''))
304
+ return undefined;
305
+ return [...head, ...Array(missing).fill(0), ...tail];
306
+ }
307
+ /** The embedded IPv4 value of an IPv4-mapped, NAT64, or 6to4 IPv6 address. */
308
+ function embeddedIpv4(groups) {
309
+ // ::ffff:a.b.c.d (IPv4-mapped): groups 0-4 zero, group 5 0xffff.
310
+ if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) {
311
+ return ((groups[6] << 16) | groups[7]) >>> 0;
312
+ }
313
+ // 64:ff9b::/96 (NAT64 well-known prefix): the v4 address is the last 32 bits.
314
+ if (groups[0] === 0x0064 && groups[1] === 0xff9b && groups.slice(2, 6).every((g) => g === 0)) {
315
+ return ((groups[6] << 16) | groups[7]) >>> 0;
316
+ }
317
+ // 2002::/16 (6to4): the v4 address is groups 1-2.
318
+ if (groups[0] === 0x2002) {
319
+ return ((groups[1] << 16) | groups[2]) >>> 0;
320
+ }
321
+ return undefined;
322
+ }
323
+ /** Classify a canonical dotted-quad IPv4 value. */
324
+ function classifyIpv4(value) {
325
+ const a = (value >>> 24) & 0xff;
326
+ const b = (value >>> 16) & 0xff;
327
+ if (a === 0)
328
+ return 'unspecified'; // 0.0.0.0/8 "this host"
329
+ if (a === 127)
330
+ return 'loopback';
331
+ if (a === 10)
332
+ return 'private';
333
+ if (a === 172 && b >= 16 && b <= 31)
334
+ return 'private';
335
+ if (a === 192 && b === 168)
336
+ return 'private';
337
+ if (a === 169 && b === 254)
338
+ return 'link-local'; // incl. 169.254.169.254 cloud metadata
339
+ if (a === 100 && b >= 64 && b <= 127)
340
+ return 'private'; // CGNAT shared space
341
+ if (a === 192 && b === 0)
342
+ return 'reserved'; // 192.0.0.0/24 protocol assignments, 192.0.2.0/24 documentation
343
+ if (a === 198 && (b === 18 || b === 19))
344
+ return 'reserved'; // benchmarking
345
+ if (a === 198 && b === 51)
346
+ return 'reserved'; // documentation
347
+ if (a === 203 && b === 0)
348
+ return 'reserved'; // documentation
349
+ if (a >= 224 && a <= 239)
350
+ return 'multicast';
351
+ if (a >= 240)
352
+ return 'reserved';
353
+ return 'public';
354
+ }
355
+ /** Classify one resolved address (IPv4 or IPv6, canonical or transition form). */
356
+ function classifyAddress(address) {
357
+ const v4 = parseIpv4(address);
358
+ if (v4 !== undefined)
359
+ return classifyIpv4(v4);
360
+ const groups = parseIpv6Groups(address);
361
+ if (groups === undefined)
362
+ return 'reserved'; // unparseable is never public
363
+ const embedded = embeddedIpv4(groups);
364
+ if (embedded !== undefined)
365
+ return classifyIpv4(embedded);
366
+ const first = groups[0];
367
+ if (groups.every((g) => g === 0))
368
+ return 'unspecified'; // ::
369
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
370
+ return 'loopback'; // ::1
371
+ if ((first & 0xfe00) === 0xfc00)
372
+ return 'private'; // fc00::/7 unique local
373
+ if ((first & 0xffc0) === 0xfe80)
374
+ return 'link-local'; // fe80::/10
375
+ if ((first & 0xff00) === 0xff00)
376
+ return 'multicast'; // ff00::/8
377
+ if (first === 0x2001 && groups[1] === 0x0db8)
378
+ return 'reserved'; // documentation
379
+ return 'public';
380
+ }
381
+ /** Whether one hostname string is already an IP literal (bracketed v6 or dotted v4). */
382
+ function isIpLiteral(hostname) {
383
+ const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
384
+ return parseIpv4(bare) !== undefined || parseIpv6Groups(bare) !== undefined;
385
+ }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import type { Context } from '@deepseek-ai/cordis';
13
13
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
14
- import type { ReaderAddFailure, ReaderAddOutcome, ReaderAddRefusal, ReaderAnnotationOutcome, ReaderBackfillCandidate, ReaderEntryBodyView, ReaderEntryFetchState, ReaderBody, ReaderCapabilities, ReaderEntryTranslationView, ReaderMutationOutcome, ReaderRecentEntry, ReaderRefreshResult, ReaderSentenceLearn, ReaderSourceSummary, ReaderStorageStats, ReaderTag } from './types.ts';
14
+ import type { ReaderAddFailure, ReaderAddOutcome, ReaderAddRefusal, ReaderAnnotationOutcome, ReaderBackfillCandidate, ReaderEntryBodyView, ReaderImageResult, ReaderEntryFetchState, ReaderBody, ReaderCapabilities, ReaderEntryTranslationView, ReaderMutationOutcome, ReaderRecentEntry, ReaderRefreshResult, ReaderSentenceLearn, ReaderSourceSummary, ReaderStorageStats, ReaderTag } from './types.ts';
15
15
  declare module '@deepseek-ai/cordis' {
16
16
  interface Context {
17
17
  readerRemote: ReaderRemoteService;
@@ -80,6 +80,14 @@ export declare class ReaderRemoteService extends TypertRemoteService<ReaderRemot
80
80
  truncated?: boolean;
81
81
  error?: string;
82
82
  }>;
83
+ /**
84
+ * Fetch one image the browser could not load cross-origin (CORP /
85
+ * hotlink-protected), so the pane can re-point the failed `<img>` at the
86
+ * bytes. Called per failed image, never for a healthy one.
87
+ */
88
+ fetchImage(request: {
89
+ url: string;
90
+ }): Promise<ReaderImageResult>;
83
91
  /** Cache the markup the browser extracted for one entry. */
84
92
  storeEntryBody(request: {
85
93
  entryId: string;
@@ -46,6 +46,7 @@ let ReaderRemoteService = (() => {
46
46
  let _getBodies_decorators;
47
47
  let _getEntryBody_decorators;
48
48
  let _fetchEntryBody_decorators;
49
+ let _fetchImage_decorators;
49
50
  let _storeEntryBody_decorators;
50
51
  let _entryFetchStates_decorators;
51
52
  let _getRawBody_decorators;
@@ -80,6 +81,7 @@ let ReaderRemoteService = (() => {
80
81
  _getBodies_decorators = [Remote('getBodies')];
81
82
  _getEntryBody_decorators = [Remote('getEntryBody')];
82
83
  _fetchEntryBody_decorators = [Remote('fetchEntryBody')];
84
+ _fetchImage_decorators = [Remote('fetchImage')];
83
85
  _storeEntryBody_decorators = [Remote('storeEntryBody')];
84
86
  _entryFetchStates_decorators = [Remote('entryFetchStates')];
85
87
  _getRawBody_decorators = [Remote('getRawBody')];
@@ -111,6 +113,7 @@ let ReaderRemoteService = (() => {
111
113
  __esDecorate(this, null, _getBodies_decorators, { kind: "method", name: "getBodies", static: false, private: false, access: { has: obj => "getBodies" in obj, get: obj => obj.getBodies }, metadata: _metadata }, null, _instanceExtraInitializers);
112
114
  __esDecorate(this, null, _getEntryBody_decorators, { kind: "method", name: "getEntryBody", static: false, private: false, access: { has: obj => "getEntryBody" in obj, get: obj => obj.getEntryBody }, metadata: _metadata }, null, _instanceExtraInitializers);
113
115
  __esDecorate(this, null, _fetchEntryBody_decorators, { kind: "method", name: "fetchEntryBody", static: false, private: false, access: { has: obj => "fetchEntryBody" in obj, get: obj => obj.fetchEntryBody }, metadata: _metadata }, null, _instanceExtraInitializers);
116
+ __esDecorate(this, null, _fetchImage_decorators, { kind: "method", name: "fetchImage", static: false, private: false, access: { has: obj => "fetchImage" in obj, get: obj => obj.fetchImage }, metadata: _metadata }, null, _instanceExtraInitializers);
114
117
  __esDecorate(this, null, _storeEntryBody_decorators, { kind: "method", name: "storeEntryBody", static: false, private: false, access: { has: obj => "storeEntryBody" in obj, get: obj => obj.storeEntryBody }, metadata: _metadata }, null, _instanceExtraInitializers);
115
118
  __esDecorate(this, null, _entryFetchStates_decorators, { kind: "method", name: "entryFetchStates", static: false, private: false, access: { has: obj => "entryFetchStates" in obj, get: obj => obj.entryFetchStates }, metadata: _metadata }, null, _instanceExtraInitializers);
116
119
  __esDecorate(this, null, _getRawBody_decorators, { kind: "method", name: "getRawBody", static: false, private: false, access: { has: obj => "getRawBody" in obj, get: obj => obj.getRawBody }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -181,6 +184,14 @@ let ReaderRemoteService = (() => {
181
184
  fetchEntryBody(request) {
182
185
  return this.core.fetchEntryBody(request);
183
186
  }
187
+ /**
188
+ * Fetch one image the browser could not load cross-origin (CORP /
189
+ * hotlink-protected), so the pane can re-point the failed `<img>` at the
190
+ * bytes. Called per failed image, never for a healthy one.
191
+ */
192
+ fetchImage(request) {
193
+ return this.core.fetchImage(request);
194
+ }
184
195
  /** Cache the markup the browser extracted for one entry. */
185
196
  storeEntryBody(request) {
186
197
  return this.core.storeEntryBody(request);
@@ -35,7 +35,7 @@
35
35
  * @module @khorsheed/dsh-reader/service
36
36
  */
37
37
  import type { Context } from '@deepseek-ai/cordis';
38
- import { type ReaderAddFailure, type ReaderAddOutcome, type ReaderAddRefusal, type ReaderAnnotationOutcome, type ReaderBackfillCandidate, type ReaderEntryBodyView, type ReaderBody, type ReaderCapabilities, type ReaderMutationOutcome, type ReaderEntryFetchState, type ReaderEntryTranslationView, type ReaderPreviewFailure, type ReaderPreviewFailureCode, type ReaderRecentEntry, type ReaderRefreshResult, type ReaderSentenceLearn, type ReaderSourceSummary, type ReaderStorageStats, type ReaderTag } from './types.ts';
38
+ import { type ReaderAddFailure, type ReaderAddOutcome, type ReaderAddRefusal, type ReaderAnnotationOutcome, type ReaderBackfillCandidate, type ReaderEntryBodyView, type ReaderBody, type ReaderCapabilities, type ReaderMutationOutcome, type ReaderEntryFetchState, type ReaderEntryTranslationView, type ReaderPreviewFailure, type ReaderPreviewFailureCode, type ReaderImageResult, type ReaderRecentEntry, type ReaderRefreshResult, type ReaderSentenceLearn, type ReaderSourceSummary, type ReaderStorageStats, type ReaderTag } from './types.ts';
39
39
  /**
40
40
  * How many entries one automatic backfill run may fetch.
41
41
  *
@@ -78,6 +78,8 @@ export declare class ReaderService {
78
78
  private disposed;
79
79
  /** The document of record when no filesystem is mounted. */
80
80
  private memoryDoc;
81
+ /** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
82
+ private readonly imageCache;
81
83
  /**
82
84
  * @param ctx - owning context.
83
85
  * @param config - optional state-root override.
@@ -230,6 +232,33 @@ export declare class ReaderService {
230
232
  truncated?: boolean;
231
233
  error?: string;
232
234
  }>;
235
+ /**
236
+ * One image the browser could not load cross-origin, fetched host-side.
237
+ *
238
+ * The pane calls this only for an `<img>` that already FAILED: the page's
239
+ * bytes arrived fine (a direct fetch gets a 200) but the origin answers them
240
+ * with `Cross-Origin-Resource-Policy: same-origin`, so the browser withholds
241
+ * them from this page no matter what the tag asks — measured on claude.dev's
242
+ * `/media/*` figures (2026-09-29), which broke with the alt text showing.
243
+ * CORP is an embedder-side check, so a host-process fetch is the way through.
244
+ *
245
+ * This deliberately does NOT ride `ctx.web.fetch`: the seam's decoded body is
246
+ * a closed html|text union and its provider refuses binary content types
247
+ * outright. The image fetch re-implements the seam's hardening at image
248
+ * scope instead (public-address validation with the connection pinned to the
249
+ * validated answers, a byte cap, a timeout, per-hop re-validation, and an
250
+ * image/* content-type gate) — see `image-fetch.ts`.
251
+ *
252
+ * Rescued bytes are kept in an in-memory LRU under a byte budget: the
253
+ * sidebar unmounts wholesale when another panel takes over, and a remount
254
+ * re-fails every blocked image before this cache can answer it.
255
+ *
256
+ * @param request - the image URL the pane failed on.
257
+ * @returns the bytes and their declared type, or why there are none.
258
+ */
259
+ fetchImage(request: {
260
+ url: string;
261
+ }): Promise<ReaderImageResult>;
233
262
  /**
234
263
  * Which entries have a body, a stored raw payload, or a recorded failure.
235
264
  *
@@ -2,6 +2,7 @@ import { MAX_RECENT_ENTRIES, ReaderStore, ReaderStoreError, boundTranslations, e
2
2
  import { defaultSourceLabel, errorMessage, isRetryablePreviewFailure, linkEntryId, DEFAULT_CACHE_POLICY, DEFAULT_TRANSLATION_BUDGET_CHARS, INLINE_BODY_MAX_CHARS, TRANSLATION_STORAGE_VERSION, } from "./types.js";
3
3
  import { delayUntilNext, isCatchUpDue } from "./schedule.js";
4
4
  import { arxivHtmlUrl } from "./arxiv.js";
5
+ import { fetchImageBytes, ImageFetchCache } from "./image-fetch.js";
5
6
  import { resolveLink } from "./link-resolvers.js";
6
7
  /**
7
8
  * How many entries one automatic backfill run may fetch.
@@ -81,6 +82,8 @@ export class ReaderService {
81
82
  disposed = false;
82
83
  /** The document of record when no filesystem is mounted. */
83
84
  memoryDoc;
85
+ /** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
86
+ imageCache = new ImageFetchCache();
84
87
  /**
85
88
  * @param ctx - owning context.
86
89
  * @param config - optional state-root override.
@@ -549,6 +552,43 @@ export class ReaderService {
549
552
  return { entryId: request.entryId, error: failure.message };
550
553
  }
551
554
  }
555
+ /**
556
+ * One image the browser could not load cross-origin, fetched host-side.
557
+ *
558
+ * The pane calls this only for an `<img>` that already FAILED: the page's
559
+ * bytes arrived fine (a direct fetch gets a 200) but the origin answers them
560
+ * with `Cross-Origin-Resource-Policy: same-origin`, so the browser withholds
561
+ * them from this page no matter what the tag asks — measured on claude.dev's
562
+ * `/media/*` figures (2026-09-29), which broke with the alt text showing.
563
+ * CORP is an embedder-side check, so a host-process fetch is the way through.
564
+ *
565
+ * This deliberately does NOT ride `ctx.web.fetch`: the seam's decoded body is
566
+ * a closed html|text union and its provider refuses binary content types
567
+ * outright. The image fetch re-implements the seam's hardening at image
568
+ * scope instead (public-address validation with the connection pinned to the
569
+ * validated answers, a byte cap, a timeout, per-hop re-validation, and an
570
+ * image/* content-type gate) — see `image-fetch.ts`.
571
+ *
572
+ * Rescued bytes are kept in an in-memory LRU under a byte budget: the
573
+ * sidebar unmounts wholesale when another panel takes over, and a remount
574
+ * re-fails every blocked image before this cache can answer it.
575
+ *
576
+ * @param request - the image URL the pane failed on.
577
+ * @returns the bytes and their declared type, or why there are none.
578
+ */
579
+ async fetchImage(request) {
580
+ const url = normalizeUrl(request.url);
581
+ if (url === undefined)
582
+ return { url: request.url, error: 'invalid-url' };
583
+ const cached = this.imageCache.get(url);
584
+ if (cached !== undefined)
585
+ return { url, mime: cached.mime, base64: cached.base64 };
586
+ const fetched = await fetchImageBytes(url);
587
+ if (!fetched.ok)
588
+ return { url, error: fetched.error };
589
+ this.imageCache.set(url, fetched.mime, fetched.base64);
590
+ return { url, mime: fetched.mime, base64: fetched.base64 };
591
+ }
552
592
  /**
553
593
  * Which entries have a body, a stored raw payload, or a recorded failure.
554
594
  *
@@ -707,4 +707,23 @@ export interface ReaderEntryBodyView {
707
707
  /** Why a fetch could not produce a body, when one was attempted. */
708
708
  readonly error?: string;
709
709
  }
710
+ /**
711
+ * One image the pane could not load cross-origin, fetched host-side.
712
+ *
713
+ * The browser-side block is CORP/hotlink protection: the origin answers the
714
+ * bytes (a direct fetch gets a 200) but withholds them from a cross-origin
715
+ * page. The rescue hands the bytes over the wire base64-encoded — the pane
716
+ * re-points the failed `<img>` at a `data:` URI built from them. A failed
717
+ * rescue carries `error` instead; the pane leaves the broken image alone.
718
+ */
719
+ export interface ReaderImageResult {
720
+ /** The URL the pane asked about (echoed, so a late answer can be matched). */
721
+ readonly url: string;
722
+ /** The image's declared content type, when the fetch produced bytes. */
723
+ readonly mime?: string;
724
+ /** The image bytes, base64-encoded, when the fetch produced them. */
725
+ readonly base64?: string;
726
+ /** Why the rescue failed, when it did. */
727
+ readonly error?: string;
728
+ }
710
729
  //# sourceMappingURL=types.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khorsheed/dsh-reader",
3
3
  "description": "Link reader tab: RSS/Atom subscriptions plus pasted article links, a card feed with a readable detail view, host-side fetch and daily refresh over a Typert Remote data face",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -41,7 +41,8 @@
41
41
  "lib/typert.remote-client.d.ts",
42
42
  "lib/typert.remote-client.js",
43
43
  "cordis.patch.yml",
44
- "locale/*.json"
44
+ "locale/*.json",
45
+ "CHANGELOG.md"
45
46
  ],
46
47
  "license": "MIT",
47
48
  "dependencies": {
@@ -49,15 +50,15 @@
49
50
  },
50
51
  "peerDependencies": {
51
52
  "@deepseek-ai/cordis": "^4.0.1",
52
- "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
53
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
54
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
55
- "@deepseek-ai/dsh-client-ui-session": "^0.1.0-rc.6",
56
- "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.7-rc.1",
57
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
58
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
59
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
60
- "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6",
53
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6 || ^0.2.0-rc.1",
54
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.2.0-rc.1",
55
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6 || ^0.2.0-rc.1",
56
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.0-rc.6 || ^0.2.0-rc.1",
57
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.7-rc.1 || ^0.2.0-rc.1",
58
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.2.0-rc.1",
59
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6 || ^0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1",
61
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6 || ^0.2.0-rc.1",
61
62
  "react": "^18.2.0"
62
63
  },
63
64
  "peerDependenciesMeta": {