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.
- package/CHANGELOG.md +44 -1
- package/README.md +50 -5
- package/package.json +3 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +376 -13
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +27 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SyncNowButton.tsx +57 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +225 -93
- package/src/components/TimelineRouteFrame.tsx +22 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +15 -11
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +23 -7
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1364 -1300
- 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 +79 -48
- package/src/lib/db.ts +33 -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 +969 -199
- 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 +30 -7
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +216 -148
- 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 +42 -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 +75 -29
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- 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
|
|
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-[
|
|
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
|
|
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-
|
|
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
|
-
"
|
|
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 =
|
|
525
|
+
export const contextStatTermClass =
|
|
526
|
+
"min-w-0 text-[13px] text-[var(--ink-soft)]";
|
|
505
527
|
|
|
506
|
-
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,
|