birdclaw 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -1,5 +1,18 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import net from "node:net";
5
+ import { Readable } from "node:stream";
6
+ import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
7
+ import { Effect } from "effect";
1
8
  import { getNativeDb } from "./db";
9
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
2
10
  import type { Database } from "./sqlite";
11
+ import {
12
+ __test__ as urlSafetyTest,
13
+ assertSafePreviewUrl,
14
+ isBlockedAddress,
15
+ } from "./url-safety";
3
16
  import {
4
17
  normalizeUrlExpansionForIndex,
5
18
  upsertUrlExpansion,
@@ -7,6 +20,8 @@ import {
7
20
 
8
21
  const FETCH_TIMEOUT_MS = 12_000;
9
22
  const MAX_HTML_CHARS = 2_000_000;
23
+ const MAX_REDIRECTS = 4;
24
+ const NO_BODY_STATUS_CODES = new Set([204, 205, 304]);
10
25
 
11
26
  export interface LinkPreviewMetadata {
12
27
  url: string;
@@ -21,7 +36,14 @@ export interface GetLinkPreviewOptions {
21
36
  shortUrl?: string | null;
22
37
  refresh?: boolean;
23
38
  fetchImpl?: typeof fetch;
39
+ resolveHost?: (hostname: string) => Promise<string[]>;
24
40
  timeoutMs?: number;
41
+ method?: "GET" | "HEAD";
42
+ }
43
+
44
+ interface ResolvedAddress {
45
+ address: string;
46
+ family: 4 | 6;
25
47
  }
26
48
 
27
49
  type UrlExpansionPreviewRow = {
@@ -44,13 +66,21 @@ function cleanText(value: string | null | undefined) {
44
66
  return cleaned.length > 0 ? cleaned : null;
45
67
  }
46
68
 
69
+ function decodeCodePoint(value: number, fallback: string) {
70
+ try {
71
+ return String.fromCodePoint(value);
72
+ } catch {
73
+ return fallback;
74
+ }
75
+ }
76
+
47
77
  function decodeHtmlEntities(value: string) {
48
78
  return value
49
- .replace(/&#(\d+);/g, (_, code: string) =>
50
- String.fromCodePoint(Number(code)),
79
+ .replace(/&#(\d+);/g, (entity: string, code: string) =>
80
+ decodeCodePoint(Number(code), entity),
51
81
  )
52
- .replace(/&#x([a-f0-9]+);/gi, (_, code: string) =>
53
- String.fromCodePoint(Number.parseInt(code, 16)),
82
+ .replace(/&#x([a-f0-9]+);/gi, (entity: string, code: string) =>
83
+ decodeCodePoint(Number.parseInt(code, 16), entity),
54
84
  )
55
85
  .replace(/&amp;/g, "&")
56
86
  .replace(/&lt;/g, "<")
@@ -123,6 +153,367 @@ function hostLabel(url: string) {
123
153
  }
124
154
  }
125
155
 
156
+ function isInjectedFetchAllowed() {
157
+ return process.env.NODE_ENV === "test" || process.env.VITEST === "true";
158
+ }
159
+
160
+ function stripAddressBrackets(value: string) {
161
+ return value.replace(/^\[|\]$/g, "");
162
+ }
163
+
164
+ function withTimeout<T>(promise: Promise<T>, ms: number) {
165
+ let timer: NodeJS.Timeout | null = null;
166
+ const timeout = new Promise<never>((_resolve, reject) => {
167
+ timer = setTimeout(
168
+ () => reject(new Error("Link preview request timed out")),
169
+ ms,
170
+ );
171
+ });
172
+ return Promise.race([promise, timeout]).finally(() => {
173
+ if (timer) clearTimeout(timer);
174
+ });
175
+ }
176
+
177
+ export async function resolvePublicAddresses(
178
+ hostname: string,
179
+ ): Promise<string[]> {
180
+ const normalized = stripAddressBrackets(hostname);
181
+ if (net.isIP(normalized)) return [normalized];
182
+ const records = await lookup(normalized, { all: true, verbatim: true });
183
+ return records.map((record) => record.address);
184
+ }
185
+
186
+ function validateResolvedAddresses(addresses: string[]) {
187
+ if (addresses.length === 0) {
188
+ throw new Error("Link preview host did not resolve");
189
+ }
190
+ if (addresses.some(isBlockedAddress)) {
191
+ throw new Error("Link preview URL resolves to a private address");
192
+ }
193
+ }
194
+
195
+ async function resolveSafeAddresses(
196
+ hostname: string,
197
+ resolveHost: (hostname: string) => Promise<string[]>,
198
+ timeoutMs: number,
199
+ ): Promise<ResolvedAddress[]> {
200
+ const normalizedHostname = stripAddressBrackets(hostname);
201
+ const addresses = await withTimeout(
202
+ resolveHost(normalizedHostname),
203
+ timeoutMs,
204
+ );
205
+ validateResolvedAddresses(addresses);
206
+ return addresses.map((address) => {
207
+ const normalized = stripAddressBrackets(address);
208
+ const family = net.isIP(normalized);
209
+ if (family !== 4 && family !== 6) {
210
+ throw new Error("Link preview host resolved to an invalid address");
211
+ }
212
+ return { address: normalized, family };
213
+ });
214
+ }
215
+
216
+ function headersFromIncoming(headers: http.IncomingHttpHeaders): HeadersInit {
217
+ const result = new Headers();
218
+ for (const [key, value] of Object.entries(headers)) {
219
+ if (Array.isArray(value)) {
220
+ for (const item of value) result.append(key, item);
221
+ } else if (typeof value === "string") {
222
+ result.set(key, value);
223
+ }
224
+ }
225
+ return result;
226
+ }
227
+
228
+ function decodedResponseBody(response: Response) {
229
+ const body = response.body;
230
+ if (!body) return null;
231
+ const encoding = response.headers
232
+ .get("content-encoding")
233
+ ?.split(",")
234
+ .at(-1)
235
+ ?.trim()
236
+ .toLowerCase();
237
+ if (!encoding || encoding === "identity") return body;
238
+ const nodeBody = Readable.fromWeb(
239
+ body as Parameters<typeof Readable.fromWeb>[0],
240
+ );
241
+ if (encoding === "gzip" || encoding === "x-gzip") {
242
+ return Readable.toWeb(nodeBody.pipe(createGunzip())) as ReadableStream;
243
+ }
244
+ if (encoding === "br") {
245
+ return Readable.toWeb(
246
+ nodeBody.pipe(createBrotliDecompress()),
247
+ ) as ReadableStream;
248
+ }
249
+ if (encoding === "deflate") {
250
+ return Readable.toWeb(nodeBody.pipe(createInflate())) as ReadableStream;
251
+ }
252
+ return body;
253
+ }
254
+
255
+ function cancelResponseBodyEffect(response: Response) {
256
+ return tryPromise(() => response.body?.cancel() ?? Promise.resolve()).pipe(
257
+ Effect.catchAll(() => Effect.void),
258
+ );
259
+ }
260
+
261
+ function respondWithResolvedAddress(
262
+ selected: ResolvedAddress,
263
+ lookupOptions: { all?: boolean } | number | undefined,
264
+ callback: (
265
+ error: Error | null,
266
+ address: string | ResolvedAddress[],
267
+ family?: 4 | 6,
268
+ ) => void,
269
+ ) {
270
+ if (typeof lookupOptions === "object" && lookupOptions?.all) {
271
+ callback(null, [selected]);
272
+ return;
273
+ }
274
+ callback(null, selected.address, selected.family);
275
+ }
276
+
277
+ function nodeSafeFetch(
278
+ url: URL,
279
+ options: {
280
+ addresses: ResolvedAddress[];
281
+ headers: Record<string, string>;
282
+ method: "GET" | "HEAD";
283
+ timeoutMs: number;
284
+ },
285
+ ) {
286
+ const deadline = Date.now() + options.timeoutMs;
287
+
288
+ function fetchAddress(address: ResolvedAddress, attemptTimeoutMs: number) {
289
+ return new Promise<Response>((resolve, reject) => {
290
+ let settled = false;
291
+ const finish = (callback: () => void) => {
292
+ if (settled) return;
293
+ settled = true;
294
+ try {
295
+ callback();
296
+ } catch (error) {
297
+ reject(error);
298
+ }
299
+ };
300
+
301
+ const client = url.protocol === "https:" ? https : http;
302
+ let wallClockTimeout: NodeJS.Timeout | null = null;
303
+ const clearWallClockTimeout = () => {
304
+ if (!wallClockTimeout) return;
305
+ clearTimeout(wallClockTimeout);
306
+ wallClockTimeout = null;
307
+ };
308
+ const setWallClockTimeout = (
309
+ ms: number,
310
+ target: { destroy: (error: Error) => void },
311
+ ) => {
312
+ clearWallClockTimeout();
313
+ wallClockTimeout = setTimeout(() => {
314
+ target.destroy(new Error("Link preview request timed out"));
315
+ }, ms);
316
+ };
317
+ const request = client.request(
318
+ url,
319
+ {
320
+ headers: options.headers,
321
+ method: options.method,
322
+ lookup: (_hostname, lookupOptions, callback) => {
323
+ respondWithResolvedAddress(address, lookupOptions, callback);
324
+ },
325
+ },
326
+ (incoming) => {
327
+ request.setTimeout(0);
328
+ setWallClockTimeout(Math.max(1, deadline - Date.now()), incoming);
329
+ incoming.once("end", clearWallClockTimeout);
330
+ incoming.once("close", clearWallClockTimeout);
331
+ incoming.once("error", clearWallClockTimeout);
332
+ finish(() => {
333
+ const status = incoming.statusCode ?? 200;
334
+ const response = new Response(
335
+ NO_BODY_STATUS_CODES.has(status)
336
+ ? null
337
+ : (Readable.toWeb(incoming) as ReadableStream),
338
+ {
339
+ headers: headersFromIncoming(incoming.headers),
340
+ status,
341
+ statusText: incoming.statusMessage,
342
+ },
343
+ );
344
+ Object.defineProperty(response, "url", {
345
+ value: url.toString(),
346
+ });
347
+ resolve(response);
348
+ });
349
+ },
350
+ );
351
+ setWallClockTimeout(attemptTimeoutMs, request);
352
+ request.setTimeout(attemptTimeoutMs, () => {
353
+ request.destroy(new Error("Link preview request timed out"));
354
+ });
355
+ request.on("error", (error) => {
356
+ clearWallClockTimeout();
357
+ finish(() => reject(error));
358
+ });
359
+ request.end();
360
+ });
361
+ }
362
+
363
+ return options.addresses.reduce<Promise<Response>>(
364
+ (previous, address, index) =>
365
+ previous.catch((error: unknown) => {
366
+ const remainingMs = deadline - Date.now();
367
+ if (remainingMs <= 0) throw error;
368
+ const remainingAddresses = options.addresses.length - index;
369
+ const attemptTimeoutMs = Math.max(
370
+ 1,
371
+ Math.ceil(remainingMs / remainingAddresses),
372
+ );
373
+ return fetchAddress(address, attemptTimeoutMs);
374
+ }),
375
+ Promise.reject(new Error("Link preview host did not resolve")),
376
+ );
377
+ }
378
+
379
+ export function safePreviewFetchEffect(
380
+ url: string,
381
+ options: Pick<
382
+ GetLinkPreviewOptions,
383
+ "fetchImpl" | "method" | "resolveHost" | "timeoutMs"
384
+ >,
385
+ ) {
386
+ const resolveHost =
387
+ options.resolveHost ??
388
+ (options.fetchImpl
389
+ ? null
390
+ : (hostname: string) => resolvePublicAddresses(hostname));
391
+ const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
392
+ const method = options.method ?? "GET";
393
+ const deadline = Date.now() + timeoutMs;
394
+ const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
395
+ const headers: Record<string, string> = {
396
+ "user-agent":
397
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 birdclaw/0.4",
398
+ accept:
399
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
400
+ "accept-language": "en-US,en;q=0.9",
401
+ };
402
+
403
+ return Effect.gen(function* () {
404
+ if (options.fetchImpl && !isInjectedFetchAllowed()) {
405
+ return yield* Effect.fail(
406
+ new Error("Custom link preview fetch is only available in tests"),
407
+ );
408
+ }
409
+
410
+ let currentUrl = url;
411
+ for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
412
+ if (Date.now() >= deadline) {
413
+ return yield* Effect.fail(new Error("Link preview request timed out"));
414
+ }
415
+ let remainingMs = remainingTimeoutMs();
416
+ const parsed = yield* Effect.try({
417
+ try: () => assertSafePreviewUrl(currentUrl),
418
+ catch: (error) => error,
419
+ });
420
+ if (options.fetchImpl && resolveHost) {
421
+ yield* tryPromise(() =>
422
+ resolveSafeAddresses(parsed.hostname, resolveHost, remainingMs),
423
+ );
424
+ }
425
+ if (Date.now() >= deadline) {
426
+ return yield* Effect.fail(new Error("Link preview request timed out"));
427
+ }
428
+ remainingMs = remainingTimeoutMs();
429
+
430
+ const response = options.fetchImpl
431
+ ? yield* tryPromise(
432
+ () =>
433
+ options.fetchImpl?.(parsed.toString(), {
434
+ headers,
435
+ method,
436
+ redirect: "manual",
437
+ signal: AbortSignal.timeout(remainingMs),
438
+ }) as Promise<Response>,
439
+ )
440
+ : yield* tryPromise(() =>
441
+ resolveSafeAddresses(
442
+ parsed.hostname,
443
+ options.resolveHost ?? resolvePublicAddresses,
444
+ remainingMs,
445
+ ).then((addresses) =>
446
+ Date.now() >= deadline
447
+ ? Promise.reject(new Error("Link preview request timed out"))
448
+ : nodeSafeFetch(parsed, {
449
+ addresses,
450
+ headers,
451
+ method,
452
+ timeoutMs: remainingTimeoutMs(),
453
+ }),
454
+ ),
455
+ );
456
+ if (response.status < 300 || response.status >= 400) return response;
457
+
458
+ const location = response.headers.get("location");
459
+ if (!location) return response;
460
+ yield* cancelResponseBodyEffect(response);
461
+ if (redirect === MAX_REDIRECTS) {
462
+ return yield* Effect.fail(
463
+ new Error("Link preview redirected too many times"),
464
+ );
465
+ }
466
+ const nextUrl = yield* Effect.try({
467
+ try: () => new URL(location, parsed).toString(),
468
+ catch: (error) => error,
469
+ });
470
+ currentUrl = nextUrl;
471
+ }
472
+ return yield* Effect.fail(
473
+ new Error("Link preview redirected too many times"),
474
+ );
475
+ });
476
+ }
477
+
478
+ function readResponseTextEffect(response: Response) {
479
+ const contentLength = Number(response.headers.get("content-length") ?? 0);
480
+ if (Number.isFinite(contentLength) && contentLength > MAX_HTML_CHARS) {
481
+ return tryPromise(() => response.body?.cancel() ?? Promise.resolve()).pipe(
482
+ Effect.flatMap(() =>
483
+ Effect.fail(new Error("Link preview response is too large")),
484
+ ),
485
+ );
486
+ }
487
+ const reader = decodedResponseBody(response)?.getReader();
488
+ if (!reader) return tryPromise(() => response.text());
489
+ const decoder = new TextDecoder();
490
+ let total = 0;
491
+ let text = "";
492
+ return Effect.gen(function* () {
493
+ for (;;) {
494
+ const { done, value } = yield* tryPromise(() => reader.read());
495
+ if (done) break;
496
+ total += value.byteLength;
497
+ if (total > MAX_HTML_CHARS) {
498
+ yield* tryPromise(() =>
499
+ reader.cancel(new Error("Link preview response is too large")),
500
+ );
501
+ return yield* Effect.fail(
502
+ new Error("Link preview response is too large"),
503
+ );
504
+ }
505
+ text += decoder.decode(value, { stream: true });
506
+ }
507
+ return text + decoder.decode();
508
+ }).pipe(
509
+ Effect.ensuring(
510
+ Effect.sync(() => {
511
+ reader.releaseLock();
512
+ }),
513
+ ),
514
+ );
515
+ }
516
+
126
517
  function youtubeThumbnail(url: string) {
127
518
  try {
128
519
  const parsed = new URL(url);
@@ -183,52 +574,59 @@ export function extractLinkPreviewMetadata(
183
574
  };
184
575
  }
185
576
 
186
- export async function fetchLinkPreviewMetadata(
577
+ export function fetchLinkPreviewMetadataEffect(
187
578
  url: string,
188
- options: Pick<GetLinkPreviewOptions, "fetchImpl" | "timeoutMs"> = {},
189
- ): Promise<LinkPreviewMetadata> {
190
- const fetchImpl = options.fetchImpl ?? globalThis.fetch;
191
- const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
192
- const headers = {
193
- "user-agent":
194
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 birdclaw/0.4",
195
- accept:
196
- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
197
- "accept-language": "en-US,en;q=0.9",
198
- } satisfies HeadersInit;
199
-
200
- try {
201
- const response = await fetchImpl(url, {
202
- headers,
203
- redirect: "follow",
204
- signal: AbortSignal.timeout(timeoutMs),
205
- });
579
+ options: Pick<
580
+ GetLinkPreviewOptions,
581
+ "fetchImpl" | "resolveHost" | "timeoutMs"
582
+ > = {},
583
+ ): Effect.Effect<LinkPreviewMetadata> {
584
+ return Effect.gen(function* () {
585
+ const response = yield* safePreviewFetchEffect(url, options);
206
586
  const finalUrl = response.url || url;
207
587
  const contentType = response.headers.get("content-type") ?? "";
208
588
  if (!response.ok) {
209
- throw new Error(`HTTP ${response.status}`);
589
+ yield* cancelResponseBodyEffect(response);
590
+ return yield* Effect.fail(new Error(`HTTP ${response.status}`));
210
591
  }
211
592
  if (contentType.toLowerCase().startsWith("image/")) {
593
+ yield* cancelResponseBodyEffect(response);
212
594
  return {
213
595
  url: finalUrl,
214
596
  title: hostLabel(finalUrl),
215
597
  description: null,
216
598
  imageUrl: finalUrl,
217
599
  siteName: hostLabel(finalUrl),
218
- };
600
+ } satisfies LinkPreviewMetadata;
219
601
  }
220
- const html = (await response.text()).slice(0, MAX_HTML_CHARS);
221
- return extractLinkPreviewMetadata(html, finalUrl);
222
- } catch (error) {
223
- return {
224
- url,
225
- title: hostLabel(url),
226
- description: null,
227
- imageUrl: youtubeThumbnail(url),
228
- siteName: hostLabel(url),
229
- error: error instanceof Error ? error.message : String(error),
230
- };
231
- }
602
+ const content = yield* readResponseTextEffect(response);
603
+ return yield* Effect.try({
604
+ try: () =>
605
+ extractLinkPreviewMetadata(content.slice(0, MAX_HTML_CHARS), finalUrl),
606
+ catch: (error) => error,
607
+ });
608
+ }).pipe(
609
+ Effect.catchAll((error) =>
610
+ Effect.succeed({
611
+ url,
612
+ title: hostLabel(url),
613
+ description: null,
614
+ imageUrl: youtubeThumbnail(url),
615
+ siteName: hostLabel(url),
616
+ error: error instanceof Error ? error.message : String(error),
617
+ }),
618
+ ),
619
+ );
620
+ }
621
+
622
+ export function fetchLinkPreviewMetadata(
623
+ url: string,
624
+ options: Pick<
625
+ GetLinkPreviewOptions,
626
+ "fetchImpl" | "resolveHost" | "timeoutMs"
627
+ > = {},
628
+ ): Promise<LinkPreviewMetadata> {
629
+ return runEffectPromise(fetchLinkPreviewMetadataEffect(url, options));
232
630
  }
233
631
 
234
632
  function readCachedPreview(
@@ -267,18 +665,29 @@ function readCachedPreview(
267
665
  }
268
666
 
269
667
  function hasUsefulPreview(row: UrlExpansionPreviewRow) {
668
+ if (row.status !== "hit") return false;
669
+ try {
670
+ assertSafePreviewUrl(row.final_url || row.expanded_url || row.short_url);
671
+ if (row.image_url) assertSafePreviewUrl(row.image_url);
672
+ } catch {
673
+ return false;
674
+ }
270
675
  return Boolean(
271
676
  row.title || row.description || row.image_url || row.site_name,
272
677
  );
273
678
  }
274
679
 
275
680
  function rowToPreview(row: UrlExpansionPreviewRow): LinkPreviewMetadata {
276
- const url = row.final_url || row.expanded_url || row.short_url;
681
+ const url = assertSafePreviewUrl(
682
+ row.final_url || row.expanded_url || row.short_url,
683
+ ).toString();
277
684
  return {
278
685
  url,
279
686
  title: row.title ?? null,
280
687
  description: row.description ?? null,
281
- imageUrl: row.image_url ?? null,
688
+ imageUrl: row.image_url
689
+ ? assertSafePreviewUrl(row.image_url).toString()
690
+ : null,
282
691
  siteName: row.site_name ?? null,
283
692
  ...(row.error ? { error: row.error } : {}),
284
693
  };
@@ -310,25 +719,36 @@ function persistPreview(
310
719
  );
311
720
  }
312
721
 
313
- export async function getOrFetchLinkPreview(
722
+ export function getOrFetchLinkPreviewEffect(
314
723
  url: string,
315
724
  options: GetLinkPreviewOptions = {},
316
- ): Promise<LinkPreviewMetadata> {
317
- const db = getNativeDb({ seedDemoData: false });
318
- const cached = readCachedPreview(db, url, options.shortUrl);
319
- if (cached && hasUsefulPreview(cached) && !options.refresh) {
320
- return rowToPreview(cached);
321
- }
725
+ ): Effect.Effect<LinkPreviewMetadata> {
726
+ return Effect.gen(function* () {
727
+ const db = getNativeDb({ seedDemoData: false });
728
+ const cached = readCachedPreview(db, url, options.shortUrl);
729
+ if (cached && hasUsefulPreview(cached) && !options.refresh) {
730
+ return rowToPreview(cached);
731
+ }
322
732
 
323
- const preview = await fetchLinkPreviewMetadata(
324
- cached?.final_url || cached?.expanded_url || url,
325
- options,
326
- );
327
- persistPreview(db, url, options.shortUrl, cached, preview);
328
- return preview;
733
+ const preview = yield* fetchLinkPreviewMetadataEffect(
734
+ cached?.final_url || cached?.expanded_url || url,
735
+ options,
736
+ );
737
+ persistPreview(db, url, options.shortUrl, cached, preview);
738
+ return preview;
739
+ });
740
+ }
741
+
742
+ export function getOrFetchLinkPreview(
743
+ url: string,
744
+ options: GetLinkPreviewOptions = {},
745
+ ): Promise<LinkPreviewMetadata> {
746
+ return runEffectPromise(getOrFetchLinkPreviewEffect(url, options));
329
747
  }
330
748
 
331
749
  export const __test__ = {
750
+ ...urlSafetyTest,
332
751
  decodeHtmlEntities,
752
+ respondWithResolvedAddress,
333
753
  youtubeThumbnail,
334
754
  };