birdclaw 0.5.1 → 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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -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 +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -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,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)]";
@@ -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,