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
package/src/lib/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ResourceKind = "home" | "mentions" | "authored" | "dms";
1
+ export type ResourceKind = "home" | "mentions" | "authored" | "search" | "dms";
2
2
  export type InboxKind = "mixed" | "mentions" | "dms";
3
3
 
4
4
  export type ReplyFilter = "all" | "replied" | "unreplied";
@@ -9,6 +9,9 @@ export interface AccountRecord {
9
9
  name: string;
10
10
  handle: string;
11
11
  externalUserId?: string | null;
12
+ profileId?: string;
13
+ avatarHue?: number;
14
+ avatarUrl?: string;
12
15
  transport: string;
13
16
  isDefault: number;
14
17
  createdAt: string;
@@ -124,6 +127,11 @@ export interface EmbeddedTweet {
124
127
  text: string;
125
128
  createdAt: string;
126
129
  replyToId?: string | null;
130
+ isReplied?: boolean;
131
+ likeCount?: number;
132
+ mediaCount?: number;
133
+ bookmarked?: boolean;
134
+ liked?: boolean;
127
135
  author: ProfileRecord;
128
136
  entities: TweetEntities;
129
137
  media: TweetMediaItem[];
@@ -157,7 +165,7 @@ export interface TimelineItem {
157
165
  id: string;
158
166
  accountId: string;
159
167
  accountHandle: string;
160
- kind: "home" | "mention" | "authored" | "like" | "bookmark";
168
+ kind: "home" | "mention" | "authored" | "search" | "like" | "bookmark";
161
169
  text: string;
162
170
  searchSnippet?: string;
163
171
  createdAt: string;
@@ -172,6 +180,7 @@ export interface TimelineItem {
172
180
  media: TweetMediaItem[];
173
181
  replyToTweet?: EmbeddedTweet | null;
174
182
  quotedTweet?: EmbeddedTweet | null;
183
+ retweetedTweet?: EmbeddedTweet | null;
175
184
  qualityReason?: string | null;
176
185
  }
177
186
 
@@ -289,6 +298,7 @@ export interface LinkInsightItem {
289
298
  }
290
299
 
291
300
  export interface LinkInsightQuery {
301
+ account?: string;
292
302
  kind?: LinkInsightKind;
293
303
  range?: LinkInsightRange;
294
304
  sort?: LinkInsightSort;
@@ -327,6 +337,8 @@ export interface DmConversationItem {
327
337
  accountHandle: string;
328
338
  title: string;
329
339
  searchSnippet?: string;
340
+ inboxKind?: "accepted" | "request";
341
+ isMessageRequest?: boolean;
330
342
  lastMessageAt: string;
331
343
  lastMessagePreview: string;
332
344
  unreadCount: number;
@@ -344,6 +356,7 @@ export interface TimelineQuery {
344
356
  replyFilter?: ReplyFilter;
345
357
  since?: string;
346
358
  until?: string;
359
+ untilId?: string;
347
360
  includeReplies?: boolean;
348
361
  qualityFilter?: TimelineQualityFilter;
349
362
  lowQualityThreshold?: number;
@@ -356,14 +369,17 @@ export interface TimelineQuery {
356
369
  export interface DmQuery {
357
370
  account?: string;
358
371
  conversationIds?: string[];
372
+ inbox?: "all" | "accepted" | "requests";
359
373
  participant?: string;
360
374
  search?: string;
361
375
  replyFilter?: ReplyFilter;
376
+ since?: string;
377
+ until?: string;
362
378
  minFollowers?: number;
363
379
  maxFollowers?: number;
364
380
  minInfluenceScore?: number;
365
381
  maxInfluenceScore?: number;
366
- sort?: "recent" | "influence";
382
+ sort?: "recent" | "followers" | "influence";
367
383
  context?: number;
368
384
  limit?: number;
369
385
  }
@@ -375,6 +391,40 @@ export interface TransportStatus {
375
391
  rawStatus?: string;
376
392
  }
377
393
 
394
+ export type LiveDataSourceKind = "birdclaw" | "bird" | "xurl";
395
+
396
+ export interface LiveDataSourceAccount {
397
+ id?: string;
398
+ username?: string;
399
+ handle?: string;
400
+ app?: string;
401
+ isDefault?: boolean;
402
+ }
403
+
404
+ export interface LiveDataSourceStatus {
405
+ source: LiveDataSourceKind;
406
+ label: string;
407
+ works: boolean;
408
+ installed?: boolean;
409
+ status: "ok" | "warning" | "error";
410
+ detail: string;
411
+ accounts: LiveDataSourceAccount[];
412
+ }
413
+
414
+ export interface LiveDataSourceCapability {
415
+ key: string;
416
+ label: string;
417
+ primary: LiveDataSourceKind;
418
+ fallbacks: LiveDataSourceKind[];
419
+ notes?: string;
420
+ }
421
+
422
+ export interface LiveDataSourcesResponse {
423
+ generatedAt: string;
424
+ sources: LiveDataSourceStatus[];
425
+ capabilities: LiveDataSourceCapability[];
426
+ }
427
+
378
428
  export type ModerationAction = "block" | "unblock" | "mute" | "unmute";
379
429
  export type ModerationTransportKind = "bird" | "xurl" | "x-web";
380
430
 
@@ -435,6 +485,7 @@ export interface InboxItem {
435
485
 
436
486
  export interface InboxQuery {
437
487
  kind?: InboxKind;
488
+ account?: string;
438
489
  minScore?: number;
439
490
  hideLowSignal?: boolean;
440
491
  limit?: number;
@@ -595,6 +646,27 @@ export interface XurlMentionsResponse {
595
646
  meta?: Record<string, unknown>;
596
647
  }
597
648
 
649
+ export interface XurlDmEvent {
650
+ id: string;
651
+ event_type?: string;
652
+ text?: string;
653
+ created_at?: string;
654
+ dm_conversation_id?: string;
655
+ sender_id?: string;
656
+ participant_ids?: string[];
657
+ attachments?: Record<string, unknown>;
658
+ entities?: Record<string, unknown>;
659
+ referenced_tweets?: XurlReferencedTweet[];
660
+ }
661
+
662
+ export interface XurlDmEventsResponse {
663
+ data: XurlDmEvent[];
664
+ includes?: {
665
+ users?: XurlMentionUser[];
666
+ };
667
+ meta?: Record<string, unknown>;
668
+ }
669
+
598
670
  export interface XurlTweetsResponse {
599
671
  data: XurlTweetData[];
600
672
  includes?: {
package/src/lib/ui.ts CHANGED
@@ -9,9 +9,15 @@ export const bodyClass =
9
9
  export const siteShellClass =
10
10
  "mx-auto flex min-h-screen w-full max-w-[1280px] gap-0";
11
11
 
12
+ export const siteShellDmClass =
13
+ "mx-auto flex min-h-screen w-full max-w-[1280px] gap-0";
14
+
12
15
  export const sidebarShellClass =
13
16
  "sticky top-0 z-30 flex h-screen w-[72px] shrink-0 flex-col justify-between border-r border-[var(--line)] bg-[var(--bg)] px-2 py-3 min-[1100px]:w-[260px] min-[1100px]:px-3";
14
17
 
18
+ export const sidebarShellCompactClass =
19
+ "sticky top-0 z-30 flex h-screen w-[72px] shrink-0 flex-col justify-between border-r border-[var(--line)] bg-[var(--bg)] px-2 py-3";
20
+
15
21
  export const sidebarBrandClass =
16
22
  "flex items-center gap-2.5 px-2 py-2 text-[var(--ink)] min-[1100px]:px-3";
17
23
 
@@ -20,6 +26,8 @@ export const sidebarBrandMarkClass = "grid size-10 place-items-center";
20
26
  export const sidebarBrandCopyClass =
21
27
  "hidden flex-col leading-tight min-[1100px]:flex";
22
28
 
29
+ export const sidebarBrandCopyCompactClass = "sr-only";
30
+
23
31
  export const sidebarBrandTitleClass = "text-[15px] font-bold tracking-tight";
24
32
 
25
33
  export const sidebarBrandTaglineClass = "text-[12px] text-[var(--ink-soft)]";
@@ -31,16 +39,24 @@ export const sidebarFooterClass = "flex flex-col gap-2 pb-1";
31
39
  export const navLinkClass =
32
40
  "nav-link group flex items-center justify-center gap-4 rounded-full px-3 py-2.5 text-[15px] text-[var(--ink)] transition-colors duration-150 hover:bg-[var(--bg-hover)] min-[1100px]:justify-start";
33
41
 
42
+ export const navLinkCompactClass =
43
+ "nav-link group flex items-center justify-center rounded-full px-3 py-2.5 text-[15px] text-[var(--ink)] transition-colors duration-150 hover:bg-[var(--bg-hover)]";
44
+
34
45
  export const navLinkActiveClass = "nav-link-active font-bold";
35
46
 
36
47
  export const navLinkIconClass = "shrink-0";
37
48
 
38
49
  export const navLinkLabelClass = "sr-only min-[1100px]:not-sr-only";
39
50
 
51
+ export const navLinkLabelCompactClass = "sr-only";
52
+
40
53
  /* Main column. */
41
54
  export const mainColumnClass =
42
55
  "flex w-full min-w-0 max-w-[680px] flex-1 flex-col border-x border-[var(--line)] bg-[var(--bg)]";
43
56
 
57
+ export const mainColumnDmClass =
58
+ "flex w-full min-w-0 flex-1 flex-col border-x border-[var(--line)] bg-[var(--bg)]";
59
+
44
60
  export const pageWrapClass = "flex w-full min-w-0 flex-1 justify-start";
45
61
 
46
62
  /* Sticky page header at top of the main column. */
@@ -66,7 +82,7 @@ export const tabButtonClass =
66
82
  export const tabButtonActiveClass = "text-[var(--ink)] font-bold";
67
83
 
68
84
  export const tabButtonIndicatorClass =
69
- "pointer-events-none absolute inset-x-0 bottom-0 mx-auto h-[3px] w-12 rounded-full bg-[var(--accent)]";
85
+ "pointer-events-none absolute bottom-0 left-1/2 h-[3px] w-12 -translate-x-1/2 rounded-full bg-[var(--accent)]";
70
86
 
71
87
  /* Feed rows: flat, hairline divider, no boxed cards. */
72
88
  export const feedClass = "flex flex-col";
@@ -230,7 +246,7 @@ export const linkPreviewHostClass =
230
246
 
231
247
  /* DM grid. */
232
248
  export const dmShellClass =
233
- "grid h-[calc(100vh-56px)] min-h-[520px] grid-cols-1 min-[1100px]:grid-cols-[320px_minmax(0,1fr)]";
249
+ "grid h-[calc(100vh-56px)] min-h-[520px] grid-cols-1 min-[960px]:grid-cols-[360px_minmax(0,1fr)] min-[1360px]:grid-cols-[400px_minmax(0,1fr)]";
234
250
 
235
251
  export const dmListClass =
236
252
  "flex flex-col overflow-y-auto border-r border-[var(--line)]";
@@ -274,10 +290,13 @@ export const dmMessageRowClass = "flex max-w-[78%] flex-col gap-1";
274
290
  export const dmMessageRowOutboundClass = "self-end items-end";
275
291
 
276
292
  export const dmMessageBubbleClass =
277
- "rounded-2xl rounded-bl-md bg-[var(--bg-active)] px-3.5 py-2 text-[15px] leading-[1.4] text-[var(--ink)]";
293
+ "rounded-2xl rounded-bl-md px-3.5 py-2 text-[15px] leading-[1.4]";
294
+
295
+ export const dmMessageBubbleInboundClass =
296
+ "bg-[var(--bg-active)] text-[var(--ink)]";
278
297
 
279
298
  export const dmMessageBubbleOutboundClass =
280
- "rounded-bl-2xl rounded-br-md bg-[var(--accent)] text-white";
299
+ "rounded-bl-2xl rounded-br-md bg-[var(--accent)] text-[var(--accent-text)]";
281
300
 
282
301
  export const dmMessageMetaClass =
283
302
  "flex items-center gap-2 text-[12px] text-[var(--ink-soft)]";
@@ -292,7 +311,7 @@ export const profilePreviewTriggerClass =
292
311
  "profile-preview-trigger inline-flex text-inherit";
293
312
 
294
313
  export const profilePreviewCardClass =
295
- "pointer-events-none absolute left-0 top-[calc(100%+8px)] z-30 grid w-[280px] translate-y-1 gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 opacity-0 shadow-[0_8px_28px_var(--shadow-strong)] transition-all duration-150 group-hover:pointer-events-auto group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0 group-focus-within:opacity-100";
314
+ "pointer-events-none absolute left-0 z-30 grid w-[280px] gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 opacity-0 shadow-[0_8px_28px_var(--shadow-strong)] transition-all duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
296
315
 
297
316
  export const profilePreviewHeaderClass = "flex items-center gap-3";
298
317
 
@@ -490,6 +509,8 @@ export const messageMetaClass = dmMessageMetaClass;
490
509
 
491
510
  export const messageBubbleClass = dmMessageBubbleClass;
492
511
 
512
+ export const messageBubbleInboundClass = dmMessageBubbleInboundClass;
513
+
493
514
  export const messageBubbleOutboundClass = dmMessageBubbleOutboundClass;
494
515
 
495
516
  export const contextHandleClass = "text-[14px] text-[var(--ink-soft)]";
@@ -499,8 +520,10 @@ export const contextBioClass = "text-[14px] leading-[1.4] text-[var(--ink)]";
499
520
  export const contextStatsClass = "flex flex-col gap-0";
500
521
 
501
522
  export const contextStatRowClass =
502
- "flex items-baseline justify-between border-t border-[var(--line)] py-2 first:border-t-0 first:pt-0";
523
+ "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-t border-[var(--line)] py-2 first:border-t-0 first:pt-0";
503
524
 
504
- export const contextStatTermClass = "text-[13px] text-[var(--ink-soft)]";
525
+ export const contextStatTermClass =
526
+ "min-w-0 text-[13px] text-[var(--ink-soft)]";
505
527
 
506
- export const contextStatValueClass = "text-[14px] font-bold text-[var(--ink)]";
528
+ export const contextStatValueClass =
529
+ "shrink-0 whitespace-nowrap text-right text-[14px] font-bold text-[var(--ink)]";
@@ -1,6 +1,13 @@
1
+ import { Effect } from "effect";
1
2
  import { getNativeDb } from "./db";
3
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
4
+ import {
5
+ resolvePublicAddresses,
6
+ safePreviewFetchEffect,
7
+ } from "./link-preview-metadata";
2
8
  import { readSyncCache, writeSyncCache } from "./sync-cache";
3
9
  import type { UrlExpansionItem } from "./types";
10
+ import { assertSafePreviewUrl } from "./url-safety";
4
11
  import {
5
12
  normalizeUrlExpansionForIndex,
6
13
  upsertUrlExpansion,
@@ -9,6 +16,7 @@ import {
9
16
  const SUCCESS_CACHE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
10
17
  const FAILURE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
11
18
  const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
19
+ const MAX_REDIRECTS = 4;
12
20
  const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
13
21
 
14
22
  interface CachedUrlExpansion {
@@ -25,6 +33,7 @@ export interface ExpandUrlsOptions {
25
33
  successMaxAgeMs?: number;
26
34
  failureMaxAgeMs?: number;
27
35
  fetchImpl?: typeof fetch;
36
+ resolveHost?: (hostname: string) => Promise<string[]>;
28
37
  timeoutMs?: number;
29
38
  }
30
39
 
@@ -40,6 +49,23 @@ function trimTrailingPunctuation(url: string) {
40
49
  return url.replace(/[.,;:!?]+$/g, "");
41
50
  }
42
51
 
52
+ function toError(error: unknown) {
53
+ return error instanceof Error ? error : new Error(String(error));
54
+ }
55
+
56
+ function trySync<T>(try_: () => T) {
57
+ return Effect.try({
58
+ try: try_,
59
+ catch: toError,
60
+ });
61
+ }
62
+
63
+ function cancelBodyEffect(response: Response) {
64
+ return tryPromise(() => response.body?.cancel() ?? Promise.resolve()).pipe(
65
+ Effect.catchAll(() => Effect.void),
66
+ );
67
+ }
68
+
43
69
  export function extractUrls(text: string) {
44
70
  return Array.from(
45
71
  new Set(
@@ -76,96 +102,241 @@ function persistExpansion(item: UrlExpansionItem) {
76
102
  upsertUrlExpansion(db, normalizeUrlExpansionForIndex(item));
77
103
  }
78
104
 
79
- async function fetchExpansion(
105
+ function fetchExpansionEffect(
80
106
  url: string,
81
107
  fetchImpl: typeof fetch,
108
+ usesInjectedFetch: boolean,
82
109
  timeoutMs: number,
83
- ): Promise<CachedUrlExpansion> {
110
+ resolveHost: ((hostname: string) => Promise<string[]>) | null,
111
+ ): Effect.Effect<CachedUrlExpansion, never> {
84
112
  const requestInit = {
85
- redirect: "follow",
113
+ redirect: "manual",
86
114
  headers: { "user-agent": "birdclaw/0.3 url-expander" },
87
115
  signal: AbortSignal.timeout(timeoutMs),
88
116
  } satisfies RequestInit;
89
117
 
90
- try {
91
- let response = await fetchImpl(url, {
92
- ...requestInit,
93
- method: "HEAD",
94
- });
118
+ return Effect.gen(function* () {
119
+ if (resolveHost) {
120
+ const headResponse = yield* safePreviewFetchEffect(url, {
121
+ ...(usesInjectedFetch ? { fetchImpl } : {}),
122
+ resolveHost,
123
+ method: "HEAD",
124
+ timeoutMs,
125
+ });
126
+ yield* cancelBodyEffect(headResponse);
127
+ const headFinalUrl = headResponse.url || url;
128
+ if (headFinalUrl !== url && headResponse.status < 400) {
129
+ yield* Effect.try({
130
+ try: () => assertSafePreviewUrl(headFinalUrl),
131
+ catch: (error) => error,
132
+ });
133
+ return {
134
+ expandedUrl: headFinalUrl,
135
+ finalUrl: headFinalUrl,
136
+ status: "hit",
137
+ } satisfies CachedUrlExpansion;
138
+ }
95
139
 
96
- if (!response.url || response.url === url || response.status >= 400) {
97
- response = await fetchImpl(url, {
98
- ...requestInit,
140
+ const response = yield* safePreviewFetchEffect(headFinalUrl, {
141
+ ...(usesInjectedFetch ? { fetchImpl } : {}),
142
+ resolveHost,
99
143
  method: "GET",
144
+ timeoutMs,
145
+ });
146
+ yield* cancelBodyEffect(response);
147
+ const finalUrl = response.url || headFinalUrl;
148
+ if (finalUrl !== url) {
149
+ yield* Effect.try({
150
+ try: () => assertSafePreviewUrl(finalUrl),
151
+ catch: (error) => error,
152
+ });
153
+ }
154
+ return {
155
+ expandedUrl: finalUrl,
156
+ finalUrl,
157
+ status: response.ok || finalUrl !== url ? "hit" : "miss",
158
+ ...(response.ok ? {} : { error: `HTTP ${response.status}` }),
159
+ } satisfies CachedUrlExpansion;
160
+ }
161
+
162
+ let currentUrl = url;
163
+ let response: Response | null = null;
164
+
165
+ for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
166
+ yield* Effect.try({
167
+ try: () => assertSafePreviewUrl(currentUrl),
168
+ catch: (error) => error,
169
+ });
170
+
171
+ response = yield* tryPromise(() =>
172
+ fetchImpl(currentUrl, {
173
+ ...requestInit,
174
+ method: "HEAD",
175
+ }),
176
+ );
177
+
178
+ if (response.status >= 300 && response.status < 400) {
179
+ const location = response.headers.get("location");
180
+ if (!location) break;
181
+ if (redirect >= MAX_REDIRECTS) {
182
+ return yield* Effect.fail(
183
+ new Error("URL expansion redirected too many times"),
184
+ );
185
+ }
186
+ currentUrl = yield* Effect.try({
187
+ try: () => new URL(location, currentUrl).toString(),
188
+ catch: (error) => error,
189
+ });
190
+ continue;
191
+ }
192
+
193
+ if (
194
+ !response.url ||
195
+ response.url === currentUrl ||
196
+ response.status >= 400
197
+ ) {
198
+ response = yield* tryPromise(() =>
199
+ fetchImpl(currentUrl, {
200
+ ...requestInit,
201
+ method: "GET",
202
+ }),
203
+ );
204
+ if (response.status >= 300 && response.status < 400) {
205
+ const location = response.headers.get("location");
206
+ if (!location) break;
207
+ if (redirect >= MAX_REDIRECTS) {
208
+ return yield* Effect.fail(
209
+ new Error("URL expansion redirected too many times"),
210
+ );
211
+ }
212
+ currentUrl = yield* Effect.try({
213
+ try: () => new URL(location, currentUrl).toString(),
214
+ catch: (error) => error,
215
+ });
216
+ continue;
217
+ }
218
+ }
219
+
220
+ break;
221
+ }
222
+
223
+ if (!response) {
224
+ return yield* Effect.fail(new Error("URL expansion failed"));
225
+ }
226
+ if (response.status >= 300 && response.status < 400) {
227
+ return yield* Effect.fail(new Error("URL expansion ended on a redirect"));
228
+ }
229
+
230
+ const finalUrl = response.url || currentUrl;
231
+ if (finalUrl !== url) {
232
+ yield* Effect.try({
233
+ try: () => assertSafePreviewUrl(finalUrl),
234
+ catch: (error) => error,
100
235
  });
101
236
  }
102
237
 
103
- const finalUrl = response.url || url;
104
238
  return {
105
239
  expandedUrl: finalUrl,
106
240
  finalUrl,
107
- status: response.ok || finalUrl !== url ? "hit" : "miss",
241
+ status:
242
+ response.ok || (finalUrl !== url && response.status < 300)
243
+ ? "hit"
244
+ : "miss",
108
245
  ...(response.ok ? {} : { error: `HTTP ${response.status}` }),
109
- };
110
- } catch (error) {
111
- return {
112
- expandedUrl: url,
113
- finalUrl: url,
114
- status: "error",
115
- error: error instanceof Error ? error.message : String(error),
116
- };
117
- }
246
+ } satisfies CachedUrlExpansion;
247
+ }).pipe(
248
+ Effect.catchAll((error) =>
249
+ Effect.succeed({
250
+ expandedUrl: url,
251
+ finalUrl: url,
252
+ status: "error" as const,
253
+ error: error instanceof Error ? error.message : String(error),
254
+ }),
255
+ ),
256
+ );
118
257
  }
119
258
 
120
- export async function expandUrls(
259
+ export function expandUrlsEffect(
121
260
  urls: string[],
122
261
  options: ExpandUrlsOptions = {},
123
- ): Promise<UrlExpansionItem[]> {
124
- const uniqueUrls = Array.from(new Set(urls));
125
- const fetchImpl = options.fetchImpl ?? globalThis.fetch;
126
- const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
127
- const results: UrlExpansionItem[] = [];
128
-
129
- for (const url of uniqueUrls) {
130
- const cached = readSyncCache<CachedUrlExpansion>(cacheKeyForUrl(url));
131
- if (cached && !options.refresh) {
132
- const maxAge =
133
- cached.value.status === "hit"
134
- ? (options.successMaxAgeMs ?? SUCCESS_CACHE_TTL_MS)
135
- : (options.failureMaxAgeMs ?? FAILURE_CACHE_TTL_MS);
136
- if (isFresh(cached.updatedAt, maxAge)) {
137
- const item = toExpansionItem(
138
- url,
139
- cached.value,
140
- "cache",
141
- cached.updatedAt,
142
- );
143
- persistExpansion(item);
144
- results.push(item);
145
- continue;
262
+ ): Effect.Effect<UrlExpansionItem[], unknown> {
263
+ return Effect.gen(function* () {
264
+ const uniqueUrls = Array.from(new Set(urls));
265
+ const usesInjectedFetch = options.fetchImpl !== undefined;
266
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
267
+ const resolveHost =
268
+ options.resolveHost ??
269
+ (options.fetchImpl
270
+ ? null
271
+ : (hostname: string) => resolvePublicAddresses(hostname));
272
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
273
+ const results: UrlExpansionItem[] = [];
274
+
275
+ for (const url of uniqueUrls) {
276
+ const cached = yield* trySync(() =>
277
+ readSyncCache<CachedUrlExpansion>(cacheKeyForUrl(url)),
278
+ );
279
+ if (cached && !options.refresh) {
280
+ const maxAge =
281
+ cached.value.status === "hit"
282
+ ? (options.successMaxAgeMs ?? SUCCESS_CACHE_TTL_MS)
283
+ : (options.failureMaxAgeMs ?? FAILURE_CACHE_TTL_MS);
284
+ if (isFresh(cached.updatedAt, maxAge)) {
285
+ const item = toExpansionItem(
286
+ url,
287
+ cached.value,
288
+ "cache",
289
+ cached.updatedAt,
290
+ );
291
+ yield* trySync(() => persistExpansion(item));
292
+ results.push(item);
293
+ continue;
294
+ }
146
295
  }
296
+
297
+ const value = yield* fetchExpansionEffect(
298
+ url,
299
+ fetchImpl,
300
+ usesInjectedFetch,
301
+ timeoutMs,
302
+ resolveHost,
303
+ );
304
+ const updatedAt = yield* trySync(() =>
305
+ writeSyncCache(cacheKeyForUrl(url), value),
306
+ );
307
+ const item = toExpansionItem(url, value, "network", updatedAt);
308
+ yield* trySync(() => persistExpansion(item));
309
+ results.push(item);
147
310
  }
148
311
 
149
- const value = await fetchExpansion(url, fetchImpl, timeoutMs);
150
- const updatedAt = writeSyncCache(cacheKeyForUrl(url), value);
151
- const item = toExpansionItem(url, value, "network", updatedAt);
152
- persistExpansion(item);
153
- results.push(item);
154
- }
312
+ return results;
313
+ });
314
+ }
155
315
 
156
- return results;
316
+ export function expandUrls(
317
+ urls: string[],
318
+ options: ExpandUrlsOptions = {},
319
+ ): Promise<UrlExpansionItem[]> {
320
+ return runEffectPromise(expandUrlsEffect(urls, options));
157
321
  }
158
322
 
159
- export async function expandUrlsFromTexts(
323
+ export function expandUrlsFromTextsEffect(
160
324
  texts: string[],
161
325
  options: ExpandUrlsOptions = {},
162
326
  ) {
163
- return expandUrls(
327
+ return expandUrlsEffect(
164
328
  texts.flatMap((text) => extractUrls(text)),
165
329
  options,
166
330
  );
167
331
  }
168
332
 
333
+ export function expandUrlsFromTexts(
334
+ texts: string[],
335
+ options: ExpandUrlsOptions = {},
336
+ ) {
337
+ return runEffectPromise(expandUrlsFromTextsEffect(texts, options));
338
+ }
339
+
169
340
  export const __test__ = {
170
341
  cacheKeyForUrl,
171
342
  trimTrailingPunctuation,