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.
- package/CHANGELOG.md +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +205 -0
- package/src/lib/db.ts +35 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +1024 -189
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +63 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- 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
|
|
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-[
|
|
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
|
|
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-
|
|
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
|
-
"
|
|
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 =
|
|
525
|
+
export const contextStatTermClass =
|
|
526
|
+
"min-w-0 text-[13px] text-[var(--ink-soft)]";
|
|
497
527
|
|
|
498
|
-
export const contextStatValueClass =
|
|
528
|
+
export const contextStatValueClass =
|
|
529
|
+
"shrink-0 whitespace-nowrap text-right text-[14px] font-bold text-[var(--ink)]";
|
package/src/lib/url-expansion.ts
CHANGED
|
@@ -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
|
-
|
|
105
|
+
function fetchExpansionEffect(
|
|
80
106
|
url: string,
|
|
81
107
|
fetchImpl: typeof fetch,
|
|
108
|
+
usesInjectedFetch: boolean,
|
|
82
109
|
timeoutMs: number,
|
|
83
|
-
|
|
110
|
+
resolveHost: ((hostname: string) => Promise<string[]>) | null,
|
|
111
|
+
): Effect.Effect<CachedUrlExpansion, never> {
|
|
84
112
|
const requestInit = {
|
|
85
|
-
redirect: "
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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:
|
|
241
|
+
status:
|
|
242
|
+
response.ok || (finalUrl !== url && response.status < 300)
|
|
243
|
+
? "hit"
|
|
244
|
+
: "miss",
|
|
108
245
|
...(response.ok ? {} : { error: `HTTP ${response.status}` }),
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
259
|
+
export function expandUrlsEffect(
|
|
121
260
|
urls: string[],
|
|
122
261
|
options: ExpandUrlsOptions = {},
|
|
123
|
-
):
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
persistExpansion(item);
|
|
153
|
-
results.push(item);
|
|
154
|
-
}
|
|
312
|
+
return results;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
155
315
|
|
|
156
|
-
|
|
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
|
|
323
|
+
export function expandUrlsFromTextsEffect(
|
|
160
324
|
texts: string[],
|
|
161
325
|
options: ExpandUrlsOptions = {},
|
|
162
326
|
) {
|
|
163
|
-
return
|
|
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,
|