birdclaw 0.5.0 → 0.6.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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  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 +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
package/src/lib/types.ts CHANGED
@@ -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[];
@@ -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;
@@ -356,14 +368,17 @@ export interface TimelineQuery {
356
368
  export interface DmQuery {
357
369
  account?: string;
358
370
  conversationIds?: string[];
371
+ inbox?: "all" | "accepted" | "requests";
359
372
  participant?: string;
360
373
  search?: string;
361
374
  replyFilter?: ReplyFilter;
375
+ since?: string;
376
+ until?: string;
362
377
  minFollowers?: number;
363
378
  maxFollowers?: number;
364
379
  minInfluenceScore?: number;
365
380
  maxInfluenceScore?: number;
366
- sort?: "recent" | "influence";
381
+ sort?: "recent" | "followers" | "influence";
367
382
  context?: number;
368
383
  limit?: number;
369
384
  }
@@ -435,6 +450,7 @@ export interface InboxItem {
435
450
 
436
451
  export interface InboxQuery {
437
452
  kind?: InboxKind;
453
+ account?: string;
438
454
  minScore?: number;
439
455
  hideLowSignal?: boolean;
440
456
  limit?: number;
@@ -595,6 +611,27 @@ export interface XurlMentionsResponse {
595
611
  meta?: Record<string, unknown>;
596
612
  }
597
613
 
614
+ export interface XurlDmEvent {
615
+ id: string;
616
+ event_type?: string;
617
+ text?: string;
618
+ created_at?: string;
619
+ dm_conversation_id?: string;
620
+ sender_id?: string;
621
+ participant_ids?: string[];
622
+ attachments?: Record<string, unknown>;
623
+ entities?: Record<string, unknown>;
624
+ referenced_tweets?: XurlReferencedTweet[];
625
+ }
626
+
627
+ export interface XurlDmEventsResponse {
628
+ data: XurlDmEvent[];
629
+ includes?: {
630
+ users?: XurlMentionUser[];
631
+ };
632
+ meta?: Record<string, unknown>;
633
+ }
634
+
598
635
  export interface XurlTweetsResponse {
599
636
  data: XurlTweetData[];
600
637
  includes?: {
package/src/lib/ui.ts CHANGED
@@ -9,18 +9,25 @@ 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
 
18
- export const sidebarBrandMarkClass =
19
- "grid size-9 place-items-center rounded-full bg-[var(--accent)] text-white";
24
+ export const sidebarBrandMarkClass = "grid size-10 place-items-center";
20
25
 
21
26
  export const sidebarBrandCopyClass =
22
27
  "hidden flex-col leading-tight min-[1100px]:flex";
23
28
 
29
+ export const sidebarBrandCopyCompactClass = "sr-only";
30
+
24
31
  export const sidebarBrandTitleClass = "text-[15px] font-bold tracking-tight";
25
32
 
26
33
  export const sidebarBrandTaglineClass = "text-[12px] text-[var(--ink-soft)]";
@@ -32,16 +39,24 @@ export const sidebarFooterClass = "flex flex-col gap-2 pb-1";
32
39
  export const navLinkClass =
33
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";
34
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
+
35
45
  export const navLinkActiveClass = "nav-link-active font-bold";
36
46
 
37
47
  export const navLinkIconClass = "shrink-0";
38
48
 
39
49
  export const navLinkLabelClass = "sr-only min-[1100px]:not-sr-only";
40
50
 
51
+ export const navLinkLabelCompactClass = "sr-only";
52
+
41
53
  /* Main column. */
42
54
  export const mainColumnClass =
43
55
  "flex w-full min-w-0 max-w-[680px] flex-1 flex-col border-x border-[var(--line)] bg-[var(--bg)]";
44
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
+
45
60
  export const pageWrapClass = "flex w-full min-w-0 flex-1 justify-start";
46
61
 
47
62
  /* Sticky page header at top of the main column. */
@@ -67,7 +82,7 @@ export const tabButtonClass =
67
82
  export const tabButtonActiveClass = "text-[var(--ink)] font-bold";
68
83
 
69
84
  export const tabButtonIndicatorClass =
70
- "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)]";
71
86
 
72
87
  /* Feed rows: flat, hairline divider, no boxed cards. */
73
88
  export const feedClass = "flex flex-col";
@@ -121,6 +136,15 @@ export const feedRowBadgeAlertClass =
121
136
  export const feedRowBadgeNeutralClass =
122
137
  "bg-[var(--bg-active)] text-[var(--ink-soft)]";
123
138
 
139
+ export const feedRowStatePillClass =
140
+ "inline-flex shrink-0 items-center gap-1 rounded-full border border-[var(--line)] bg-[var(--bg-active)] px-2 py-0.5 text-[12px] font-semibold text-[var(--ink-soft)]";
141
+
142
+ export const feedRowStatePillActiveClass =
143
+ "border-[color:color-mix(in_srgb,var(--accent)_35%,var(--line))] bg-[var(--accent-soft)] text-[var(--accent)]";
144
+
145
+ export const feedRowStatePillOpenClass =
146
+ "border-[color:color-mix(in_srgb,var(--ink-soft)_34%,var(--line))] bg-transparent text-[var(--ink-soft)]";
147
+
124
148
  /* Forms / inputs. */
125
149
  export const searchFieldShellClass =
126
150
  "flex items-center gap-2 rounded-full border border-transparent bg-[var(--bg-active)] px-4 py-2 transition-colors focus-within:border-[var(--accent)] focus-within:bg-[var(--bg)] focus-within:shadow-[0_0_0_1px_var(--accent)]";
@@ -222,7 +246,7 @@ export const linkPreviewHostClass =
222
246
 
223
247
  /* DM grid. */
224
248
  export const dmShellClass =
225
- "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)]";
226
250
 
227
251
  export const dmListClass =
228
252
  "flex flex-col overflow-y-auto border-r border-[var(--line)]";
@@ -266,10 +290,13 @@ export const dmMessageRowClass = "flex max-w-[78%] flex-col gap-1";
266
290
  export const dmMessageRowOutboundClass = "self-end items-end";
267
291
 
268
292
  export const dmMessageBubbleClass =
269
- "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)]";
270
297
 
271
298
  export const dmMessageBubbleOutboundClass =
272
- "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)]";
273
300
 
274
301
  export const dmMessageMetaClass =
275
302
  "flex items-center gap-2 text-[12px] text-[var(--ink-soft)]";
@@ -328,7 +355,7 @@ export function tweetMediaGridClass(count: number) {
328
355
 
329
356
  export function tweetMediaTileClass(index: number, count: number) {
330
357
  return cx(
331
- "tweet-media-tile relative block overflow-hidden bg-[var(--bg-active)]",
358
+ "tweet-media-tile relative block overflow-hidden border-0 bg-[var(--bg-active)] p-0 text-left",
332
359
  count === 1 && "aspect-[16/10]",
333
360
  count === 2 && "aspect-square",
334
361
  count === 3 && index === 0 && "row-span-2 aspect-[3/4]",
@@ -482,6 +509,8 @@ export const messageMetaClass = dmMessageMetaClass;
482
509
 
483
510
  export const messageBubbleClass = dmMessageBubbleClass;
484
511
 
512
+ export const messageBubbleInboundClass = dmMessageBubbleInboundClass;
513
+
485
514
  export const messageBubbleOutboundClass = dmMessageBubbleOutboundClass;
486
515
 
487
516
  export const contextHandleClass = "text-[14px] text-[var(--ink-soft)]";
@@ -491,8 +520,10 @@ export const contextBioClass = "text-[14px] leading-[1.4] text-[var(--ink)]";
491
520
  export const contextStatsClass = "flex flex-col gap-0";
492
521
 
493
522
  export const contextStatRowClass =
494
- "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";
495
524
 
496
- export const contextStatTermClass = "text-[13px] text-[var(--ink-soft)]";
525
+ export const contextStatTermClass =
526
+ "min-w-0 text-[13px] text-[var(--ink-soft)]";
497
527
 
498
- 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,