birdclaw 0.8.2 → 0.8.4
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 +33 -0
- package/README.md +10 -8
- package/bin/birdclaw.mjs +4 -66
- package/dist/cli/birdclaw.js +28165 -0
- package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
- package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
- package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
- package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
- package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
- package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
- package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
- package/dist/client/assets/chunk-C0phanGV.js +1 -0
- package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
- package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
- package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
- package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
- package/dist/client/assets/discuss-DTjaOugw.js +1 -0
- package/dist/client/assets/dms-CGd6Eogq.js +1 -0
- package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
- package/dist/client/assets/index-CEIG7S6s.js +31 -0
- package/dist/client/assets/index-bpFrQ5yG.css +1 -0
- package/dist/client/assets/likes-cLrcAQem.js +1 -0
- package/dist/client/assets/links-CM9QO1LC.js +1 -0
- package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
- package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
- package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
- package/dist/client/assets/network-map-B5a3whqj.js +2 -0
- package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
- package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
- package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
- package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
- package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
- package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
- package/dist/client/assets/react-nBylq1U5.js +1 -0
- package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
- package/dist/client/assets/routes-q7ZaW4En.js +1 -0
- package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
- package/dist/client/assets/search-Dq5kYsxA.js +1 -0
- package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
- package/dist/client/assets/styles-B8s3tt3r.css +2 -0
- package/dist/client/assets/today-CeG1O7Py.js +1 -0
- package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
- package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
- package/dist/client/assets/users-CHbKlqUr.js +1 -0
- package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
- package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
- package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
- package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
- package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
- package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
- package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
- package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
- package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
- package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
- package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
- package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
- package/dist/server/assets/blocks-DSgQghqP.js +17 -0
- package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
- package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
- package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
- package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
- package/dist/server/assets/discuss-QTo08NCN.js +338 -0
- package/dist/server/assets/dms-BuLj4bp8.js +368 -0
- package/dist/server/assets/dms-DAyQrVM2.js +240 -0
- package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
- package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
- package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
- package/dist/server/assets/likes-DBOAtsJU.js +14 -0
- package/dist/server/assets/links-DAhSjynu.js +240 -0
- package/dist/server/assets/links-DmH5lxHO.js +468 -0
- package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
- package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
- package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
- package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
- package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
- package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
- package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
- package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
- package/dist/server/assets/query-client-BdauHBgd.js +923 -0
- package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
- package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
- package/dist/server/assets/router-DqkwSPiM.js +17026 -0
- package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
- package/dist/server/assets/start-FaDOTmto.js +4 -0
- package/dist/server/assets/today-B5hVM57Z.js +17 -0
- package/dist/server/assets/today-fvGiZ7ud.js +303 -0
- package/dist/server/assets/ui-qbmcMSSX.js +137 -0
- package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
- package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
- package/dist/server/server.js +5520 -0
- package/package.json +27 -34
- package/playwright.config.ts +0 -35
- package/scripts/browser-perf.mjs +0 -427
- package/scripts/build-docs-site.mjs +0 -967
- package/scripts/docs-site-assets.mjs +0 -311
- package/scripts/run-vitest.mjs +0 -21
- package/scripts/sanitize-node-options.mjs +0 -23
- package/scripts/start-test-server.mjs +0 -42
- package/src/cli-moderation.ts +0 -210
- package/src/cli.ts +0 -2336
- package/src/components/AccountSwitcher.tsx +0 -186
- package/src/components/AppNav.tsx +0 -118
- package/src/components/AvatarChip.tsx +0 -58
- package/src/components/BrandMark.tsx +0 -67
- package/src/components/ConversationThread.tsx +0 -131
- package/src/components/DmWorkspace.tsx +0 -286
- package/src/components/EmbeddedTweetCard.tsx +0 -55
- package/src/components/FeedState.tsx +0 -147
- package/src/components/FloatingPreview.tsx +0 -382
- package/src/components/InboxCard.tsx +0 -152
- package/src/components/LinkPreviewCard.tsx +0 -292
- package/src/components/MarkdownViewer.tsx +0 -777
- package/src/components/ProfileAnalysisStream.tsx +0 -428
- package/src/components/ProfilePreview.tsx +0 -129
- package/src/components/SavedTimelineView.tsx +0 -155
- package/src/components/SmartTimestamp.tsx +0 -72
- package/src/components/SyncNowButton.tsx +0 -140
- package/src/components/ThemeSlider.tsx +0 -80
- package/src/components/TimelineCard.tsx +0 -533
- package/src/components/TimelineRouteFrame.tsx +0 -186
- package/src/components/TweetArticleCard.tsx +0 -66
- package/src/components/TweetMediaGrid.tsx +0 -185
- package/src/components/TweetRichText.tsx +0 -159
- package/src/components/account-selection.ts +0 -64
- package/src/components/useTimelineRouteData.ts +0 -323
- package/src/lib/account-sync-job.ts +0 -666
- package/src/lib/actions-transport.ts +0 -300
- package/src/lib/api-client.ts +0 -419
- package/src/lib/archive-finder.ts +0 -185
- package/src/lib/archive-import.ts +0 -2764
- package/src/lib/authored-live.ts +0 -1072
- package/src/lib/avatar-cache.ts +0 -349
- package/src/lib/backup.ts +0 -2741
- package/src/lib/bird-actions.ts +0 -294
- package/src/lib/bird-command.ts +0 -130
- package/src/lib/bird.ts +0 -1140
- package/src/lib/blocklist.ts +0 -136
- package/src/lib/blocks-write.ts +0 -167
- package/src/lib/blocks.ts +0 -389
- package/src/lib/bookmark-sync-job.ts +0 -536
- package/src/lib/client-cache.ts +0 -109
- package/src/lib/config.ts +0 -224
- package/src/lib/conversation-surface.ts +0 -205
- package/src/lib/data-sources.ts +0 -219
- package/src/lib/db.ts +0 -1135
- package/src/lib/dms-live.ts +0 -933
- package/src/lib/effect-runtime.ts +0 -45
- package/src/lib/follow-graph.ts +0 -1097
- package/src/lib/geocoding.ts +0 -296
- package/src/lib/http-effect.ts +0 -222
- package/src/lib/identity-search-index.ts +0 -369
- package/src/lib/inbox.ts +0 -241
- package/src/lib/link-index.ts +0 -641
- package/src/lib/link-insights.ts +0 -860
- package/src/lib/link-preview-metadata.ts +0 -754
- package/src/lib/location.ts +0 -137
- package/src/lib/media-fetch.ts +0 -860
- package/src/lib/media-includes.ts +0 -165
- package/src/lib/mention-threads-live.ts +0 -917
- package/src/lib/mentions-export.ts +0 -147
- package/src/lib/mentions-live.ts +0 -1238
- package/src/lib/moderation-target.ts +0 -216
- package/src/lib/moderation-write.ts +0 -131
- package/src/lib/mutes-write.ts +0 -167
- package/src/lib/mutes.ts +0 -84
- package/src/lib/ndjson-stream.ts +0 -106
- package/src/lib/network-map.ts +0 -382
- package/src/lib/openai.ts +0 -117
- package/src/lib/period-digest.ts +0 -1552
- package/src/lib/present.ts +0 -118
- package/src/lib/profile-affiliation-hydration.ts +0 -228
- package/src/lib/profile-affiliations.ts +0 -262
- package/src/lib/profile-analysis.ts +0 -1272
- package/src/lib/profile-bio-entities.ts +0 -318
- package/src/lib/profile-history.ts +0 -164
- package/src/lib/profile-hydration.ts +0 -180
- package/src/lib/profile-replies.ts +0 -77
- package/src/lib/profile-resolver.ts +0 -650
- package/src/lib/queries.ts +0 -2381
- package/src/lib/research.ts +0 -652
- package/src/lib/search-discussion.ts +0 -1016
- package/src/lib/seed.ts +0 -613
- package/src/lib/sqlite.ts +0 -147
- package/src/lib/sync-cache.ts +0 -65
- package/src/lib/theme-transition.ts +0 -113
- package/src/lib/theme.tsx +0 -165
- package/src/lib/timeline-collections-live.ts +0 -581
- package/src/lib/timeline-live.ts +0 -452
- package/src/lib/tweet-account-edges.ts +0 -48
- package/src/lib/tweet-lookup.ts +0 -46
- package/src/lib/tweet-render.ts +0 -430
- package/src/lib/tweet-search-live.ts +0 -566
- package/src/lib/types.ts +0 -723
- package/src/lib/ui.ts +0 -529
- package/src/lib/url-expansion-store.ts +0 -110
- package/src/lib/url-expansion.ts +0 -343
- package/src/lib/url-safety.ts +0 -220
- package/src/lib/web-sync.ts +0 -516
- package/src/lib/whois.ts +0 -1079
- package/src/lib/x-profile.ts +0 -365
- package/src/lib/x-web.ts +0 -199
- package/src/lib/xurl-rate-limits.ts +0 -267
- package/src/lib/xurl.ts +0 -1677
- package/src/routeTree.gen.ts +0 -723
- package/src/router.tsx +0 -20
- package/src/routes/__root.tsx +0 -79
- package/src/routes/api/action.tsx +0 -147
- package/src/routes/api/avatar.tsx +0 -50
- package/src/routes/api/blocks.tsx +0 -35
- package/src/routes/api/conversation.tsx +0 -45
- package/src/routes/api/data-sources.tsx +0 -24
- package/src/routes/api/inbox.tsx +0 -43
- package/src/routes/api/link-insights.tsx +0 -85
- package/src/routes/api/link-preview.tsx +0 -47
- package/src/routes/api/network-map.tsx +0 -55
- package/src/routes/api/period-digest.tsx +0 -101
- package/src/routes/api/profile-analysis.tsx +0 -97
- package/src/routes/api/profile-hydrate.tsx +0 -54
- package/src/routes/api/query.tsx +0 -115
- package/src/routes/api/search-discussion.tsx +0 -113
- package/src/routes/api/status.tsx +0 -28
- package/src/routes/api/sync.tsx +0 -105
- package/src/routes/api/xurl-rate-limits.tsx +0 -24
- package/src/routes/blocks.tsx +0 -377
- package/src/routes/bookmarks.tsx +0 -18
- package/src/routes/data-sources.tsx +0 -257
- package/src/routes/discuss.tsx +0 -419
- package/src/routes/dms.tsx +0 -432
- package/src/routes/inbox.tsx +0 -226
- package/src/routes/index.tsx +0 -32
- package/src/routes/likes.tsx +0 -18
- package/src/routes/links.tsx +0 -994
- package/src/routes/mentions.tsx +0 -32
- package/src/routes/network-map.tsx +0 -1035
- package/src/routes/profile-analyze.tsx +0 -112
- package/src/routes/profiles.$handle.tsx +0 -228
- package/src/routes/rate-limits.tsx +0 -309
- package/src/routes/today.tsx +0 -511
- package/src/styles.css +0 -257
- package/tsconfig.json +0 -29
- package/vite.config.ts +0 -29
- /package/{public → dist/client}/birdclaw-mark.png +0 -0
- /package/{public → dist/client}/favicon.ico +0 -0
- /package/{public → dist/client}/logo192.png +0 -0
- /package/{public → dist/client}/logo512.png +0 -0
- /package/{public → dist/client}/manifest.json +0 -0
- /package/{public → dist/client}/robots.txt +0 -0
package/src/lib/url-expansion.ts
DELETED
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
import { getNativeDb } from "./db";
|
|
3
|
-
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
4
|
-
import {
|
|
5
|
-
resolvePublicAddresses,
|
|
6
|
-
safePreviewFetchEffect,
|
|
7
|
-
} from "./link-preview-metadata";
|
|
8
|
-
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
9
|
-
import type { UrlExpansionItem } from "./types";
|
|
10
|
-
import { assertSafePreviewUrl } from "./url-safety";
|
|
11
|
-
import {
|
|
12
|
-
normalizeUrlExpansionForIndex,
|
|
13
|
-
upsertUrlExpansion,
|
|
14
|
-
} from "./url-expansion-store";
|
|
15
|
-
|
|
16
|
-
const SUCCESS_CACHE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
17
|
-
const FAILURE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
18
|
-
const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
|
|
19
|
-
const MAX_REDIRECTS = 4;
|
|
20
|
-
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
|
21
|
-
|
|
22
|
-
interface CachedUrlExpansion {
|
|
23
|
-
expandedUrl: string;
|
|
24
|
-
finalUrl: string;
|
|
25
|
-
status: UrlExpansionItem["status"];
|
|
26
|
-
title?: string;
|
|
27
|
-
description?: string | null;
|
|
28
|
-
error?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface ExpandUrlsOptions {
|
|
32
|
-
refresh?: boolean;
|
|
33
|
-
successMaxAgeMs?: number;
|
|
34
|
-
failureMaxAgeMs?: number;
|
|
35
|
-
fetchImpl?: typeof fetch;
|
|
36
|
-
resolveHost?: (hostname: string) => Promise<string[]>;
|
|
37
|
-
timeoutMs?: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function cacheKeyForUrl(url: string) {
|
|
41
|
-
return `url:expand:${url}`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function isFresh(updatedAt: string, maxAgeMs: number) {
|
|
45
|
-
return Date.now() - new Date(updatedAt).getTime() <= maxAgeMs;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function trimTrailingPunctuation(url: string) {
|
|
49
|
-
return url.replace(/[.,;:!?]+$/g, "");
|
|
50
|
-
}
|
|
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
|
-
|
|
69
|
-
export function extractUrls(text: string) {
|
|
70
|
-
return Array.from(
|
|
71
|
-
new Set(
|
|
72
|
-
Array.from(text.matchAll(URL_REGEX), (match) =>
|
|
73
|
-
trimTrailingPunctuation(match[0]),
|
|
74
|
-
),
|
|
75
|
-
),
|
|
76
|
-
).filter((url) => url.length > 0);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function toExpansionItem(
|
|
80
|
-
url: string,
|
|
81
|
-
value: CachedUrlExpansion,
|
|
82
|
-
source: UrlExpansionItem["source"],
|
|
83
|
-
updatedAt: string,
|
|
84
|
-
): UrlExpansionItem {
|
|
85
|
-
return {
|
|
86
|
-
url,
|
|
87
|
-
expandedUrl: value.expandedUrl,
|
|
88
|
-
finalUrl: value.finalUrl,
|
|
89
|
-
status: value.status,
|
|
90
|
-
source,
|
|
91
|
-
...(value.title ? { title: value.title } : {}),
|
|
92
|
-
...(value.description !== undefined
|
|
93
|
-
? { description: value.description }
|
|
94
|
-
: {}),
|
|
95
|
-
...(value.error ? { error: value.error } : {}),
|
|
96
|
-
updatedAt,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function persistExpansion(item: UrlExpansionItem) {
|
|
101
|
-
const db = getNativeDb({ seedDemoData: false });
|
|
102
|
-
upsertUrlExpansion(db, normalizeUrlExpansionForIndex(item));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function fetchExpansionEffect(
|
|
106
|
-
url: string,
|
|
107
|
-
fetchImpl: typeof fetch,
|
|
108
|
-
usesInjectedFetch: boolean,
|
|
109
|
-
timeoutMs: number,
|
|
110
|
-
resolveHost: ((hostname: string) => Promise<string[]>) | null,
|
|
111
|
-
): Effect.Effect<CachedUrlExpansion, never> {
|
|
112
|
-
const requestInit = {
|
|
113
|
-
redirect: "manual",
|
|
114
|
-
headers: { "user-agent": "birdclaw/0.3 url-expander" },
|
|
115
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
116
|
-
} satisfies RequestInit;
|
|
117
|
-
|
|
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
|
-
}
|
|
139
|
-
|
|
140
|
-
const response = yield* safePreviewFetchEffect(headFinalUrl, {
|
|
141
|
-
...(usesInjectedFetch ? { fetchImpl } : {}),
|
|
142
|
-
resolveHost,
|
|
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,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return {
|
|
239
|
-
expandedUrl: finalUrl,
|
|
240
|
-
finalUrl,
|
|
241
|
-
status:
|
|
242
|
-
response.ok || (finalUrl !== url && response.status < 300)
|
|
243
|
-
? "hit"
|
|
244
|
-
: "miss",
|
|
245
|
-
...(response.ok ? {} : { error: `HTTP ${response.status}` }),
|
|
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
|
-
);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export function expandUrlsEffect(
|
|
260
|
-
urls: string[],
|
|
261
|
-
options: ExpandUrlsOptions = {},
|
|
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
|
-
}
|
|
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);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
return results;
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
export function expandUrls(
|
|
317
|
-
urls: string[],
|
|
318
|
-
options: ExpandUrlsOptions = {},
|
|
319
|
-
): Promise<UrlExpansionItem[]> {
|
|
320
|
-
return runEffectPromise(expandUrlsEffect(urls, options));
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
export function expandUrlsFromTextsEffect(
|
|
324
|
-
texts: string[],
|
|
325
|
-
options: ExpandUrlsOptions = {},
|
|
326
|
-
) {
|
|
327
|
-
return expandUrlsEffect(
|
|
328
|
-
texts.flatMap((text) => extractUrls(text)),
|
|
329
|
-
options,
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
export function expandUrlsFromTexts(
|
|
334
|
-
texts: string[],
|
|
335
|
-
options: ExpandUrlsOptions = {},
|
|
336
|
-
) {
|
|
337
|
-
return runEffectPromise(expandUrlsFromTextsEffect(texts, options));
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
export const __test__ = {
|
|
341
|
-
cacheKeyForUrl,
|
|
342
|
-
trimTrailingPunctuation,
|
|
343
|
-
};
|
package/src/lib/url-safety.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
const PRIVATE_IPV4_RANGES = [
|
|
2
|
-
["0.0.0.0", 8],
|
|
3
|
-
["10.0.0.0", 8],
|
|
4
|
-
["100.64.0.0", 10],
|
|
5
|
-
["127.0.0.0", 8],
|
|
6
|
-
["169.254.0.0", 16],
|
|
7
|
-
["172.16.0.0", 12],
|
|
8
|
-
["192.0.0.0", 24],
|
|
9
|
-
["192.0.2.0", 24],
|
|
10
|
-
["192.168.0.0", 16],
|
|
11
|
-
["198.18.0.0", 15],
|
|
12
|
-
["198.51.100.0", 24],
|
|
13
|
-
["203.0.113.0", 24],
|
|
14
|
-
["224.0.0.0", 4],
|
|
15
|
-
["240.0.0.0", 4],
|
|
16
|
-
] satisfies Array<[string, number]>;
|
|
17
|
-
|
|
18
|
-
function ipv4ToNumber(value: string) {
|
|
19
|
-
const parts = value.split(".").map((part) => Number(part));
|
|
20
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
if (parts.some((part) => part < 0 || part > 255)) return null;
|
|
24
|
-
return (
|
|
25
|
-
(parts[0] ?? 0) * 256 ** 3 +
|
|
26
|
-
(parts[1] ?? 0) * 256 ** 2 +
|
|
27
|
-
(parts[2] ?? 0) * 256 +
|
|
28
|
-
(parts[3] ?? 0)
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function isIpv4InRange(address: string, range: string, prefix: number) {
|
|
33
|
-
const addressNumber = ipv4ToNumber(address);
|
|
34
|
-
const rangeNumber = ipv4ToNumber(range);
|
|
35
|
-
if (addressNumber === null || rangeNumber === null) return false;
|
|
36
|
-
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
37
|
-
return (addressNumber & mask) === (rangeNumber & mask);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function isPrivateIpv4(address: string) {
|
|
41
|
-
return PRIVATE_IPV4_RANGES.some(([range, prefix]) =>
|
|
42
|
-
isIpv4InRange(address, range, prefix),
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function normalizeIpv6(address: string) {
|
|
47
|
-
return address.toLowerCase().replace(/^\[|\]$/g, "");
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function parseIpv6Parts(address: string) {
|
|
51
|
-
let normalized = normalizeIpv6(address);
|
|
52
|
-
if (!normalized.includes(":")) return null;
|
|
53
|
-
|
|
54
|
-
if (normalized.includes(".")) {
|
|
55
|
-
const lastColon = normalized.lastIndexOf(":");
|
|
56
|
-
const ipv4 = normalized.slice(lastColon + 1);
|
|
57
|
-
const addressNumber = ipv4ToNumber(ipv4);
|
|
58
|
-
if (addressNumber === null) return null;
|
|
59
|
-
normalized = `${normalized.slice(0, lastColon + 1)}${(
|
|
60
|
-
(addressNumber >>> 16) &
|
|
61
|
-
0xffff
|
|
62
|
-
).toString(16)}:${(addressNumber & 0xffff).toString(16)}`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const halves = normalized.split("::");
|
|
66
|
-
if (halves.length > 2) return null;
|
|
67
|
-
const parseGroups = (value: string) =>
|
|
68
|
-
value === ""
|
|
69
|
-
? []
|
|
70
|
-
: value.split(":").map((part) => Number.parseInt(part, 16));
|
|
71
|
-
const left = parseGroups(halves[0] ?? "");
|
|
72
|
-
const right = halves.length === 2 ? parseGroups(halves[1] ?? "") : [];
|
|
73
|
-
const missingGroups = 8 - left.length - right.length;
|
|
74
|
-
if (
|
|
75
|
-
(halves.length === 1 && missingGroups !== 0) ||
|
|
76
|
-
(halves.length === 2 && missingGroups < 0) ||
|
|
77
|
-
![...left, ...right].every(
|
|
78
|
-
(part) => Number.isInteger(part) && part >= 0 && part <= 0xffff,
|
|
79
|
-
)
|
|
80
|
-
) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
return [...left, ...Array.from({ length: missingGroups }, () => 0), ...right];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function ipv4FromHexPair(parts: string[]) {
|
|
87
|
-
if (parts.length !== 2) return null;
|
|
88
|
-
const high = Number.parseInt(parts[0] ?? "", 16);
|
|
89
|
-
const low = Number.parseInt(parts[1] ?? "", 16);
|
|
90
|
-
if (
|
|
91
|
-
![high, low].every(
|
|
92
|
-
(part) => Number.isInteger(part) && part >= 0 && part <= 0xffff,
|
|
93
|
-
)
|
|
94
|
-
) {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
return [(high >> 8) & 255, high & 255, (low >> 8) & 255, low & 255].join(".");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function ipv4FromIpv6Parts(parts: number[]) {
|
|
101
|
-
const tailIpv4 = () =>
|
|
102
|
-
[
|
|
103
|
-
(parts[6] >> 8) & 255,
|
|
104
|
-
parts[6] & 255,
|
|
105
|
-
(parts[7] >> 8) & 255,
|
|
106
|
-
parts[7] & 255,
|
|
107
|
-
].join(".");
|
|
108
|
-
const hasZeroPrefix = (length: number) =>
|
|
109
|
-
parts.slice(0, length).every((part) => part === 0);
|
|
110
|
-
if (hasZeroPrefix(5) && parts[5] === 0xffff) return tailIpv4();
|
|
111
|
-
if (hasZeroPrefix(4) && parts[4] === 0xffff && parts[5] === 0) {
|
|
112
|
-
return tailIpv4();
|
|
113
|
-
}
|
|
114
|
-
if (hasZeroPrefix(6)) return tailIpv4();
|
|
115
|
-
if (parts[0] === 0x64 && parts[1] === 0xff9b && parts[2] === 0) {
|
|
116
|
-
return tailIpv4();
|
|
117
|
-
}
|
|
118
|
-
if (parts[0] === 0x64 && parts[1] === 0xff9b && parts[2] === 1) {
|
|
119
|
-
return tailIpv4();
|
|
120
|
-
}
|
|
121
|
-
if (parts[0] === 0x2002) {
|
|
122
|
-
return [
|
|
123
|
-
(parts[1] >> 8) & 255,
|
|
124
|
-
parts[1] & 255,
|
|
125
|
-
(parts[2] >> 8) & 255,
|
|
126
|
-
parts[2] & 255,
|
|
127
|
-
].join(".");
|
|
128
|
-
}
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function ipv4FromIpv6Suffix(address: string) {
|
|
133
|
-
const normalized = normalizeIpv6(address);
|
|
134
|
-
const parts = parseIpv6Parts(normalized);
|
|
135
|
-
if (parts) return ipv4FromIpv6Parts(parts);
|
|
136
|
-
const prefix = ["::ffff:", "64:ff9b::", "64:ff9b:1::", "::"].find((value) =>
|
|
137
|
-
normalized.startsWith(value),
|
|
138
|
-
);
|
|
139
|
-
if (!prefix) return null;
|
|
140
|
-
const suffix = normalized.slice(prefix.length);
|
|
141
|
-
if (ipv4ToNumber(suffix) !== null) return suffix;
|
|
142
|
-
return ipv4FromHexPair(suffix.split(":"));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function isPrivateIpv6(address: string) {
|
|
146
|
-
const normalized = normalizeIpv6(address);
|
|
147
|
-
const parts = parseIpv6Parts(normalized);
|
|
148
|
-
const mappedIpv4 = parts
|
|
149
|
-
? ipv4FromIpv6Parts(parts)
|
|
150
|
-
: ipv4FromIpv6Suffix(normalized);
|
|
151
|
-
if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
|
|
152
|
-
if (parts) {
|
|
153
|
-
const first = parts[0] ?? 0;
|
|
154
|
-
return (
|
|
155
|
-
parts.every((part) => part === 0) ||
|
|
156
|
-
parts.slice(0, 7).every((part) => part === 0) ||
|
|
157
|
-
(first & 0xfe00) === 0xfc00 ||
|
|
158
|
-
(first & 0xffc0) === 0xfe80 ||
|
|
159
|
-
(first & 0xffc0) === 0xfec0 ||
|
|
160
|
-
(first & 0xff00) === 0xff00
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function isBlockedAddress(address: string) {
|
|
167
|
-
const normalized = address.replace(/^\[|\]$/g, "");
|
|
168
|
-
if (ipv4ToNumber(normalized) !== null) return isPrivateIpv4(normalized);
|
|
169
|
-
if (normalized.includes(":")) return isPrivateIpv6(normalized);
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export function isLocalHostname(hostname: string) {
|
|
174
|
-
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
|
175
|
-
return (
|
|
176
|
-
normalized === "localhost" ||
|
|
177
|
-
normalized.endsWith(".localhost") ||
|
|
178
|
-
normalized.endsWith(".local") ||
|
|
179
|
-
normalized.endsWith(".internal") ||
|
|
180
|
-
normalized.endsWith(".test")
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export function assertSafePreviewUrl(url: string) {
|
|
185
|
-
const parsed = new URL(url);
|
|
186
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
187
|
-
throw new Error("Link preview URL must use http or https");
|
|
188
|
-
}
|
|
189
|
-
if (parsed.username || parsed.password) {
|
|
190
|
-
throw new Error("Link preview URL must not include credentials");
|
|
191
|
-
}
|
|
192
|
-
if (isLocalHostname(parsed.hostname) || isBlockedAddress(parsed.hostname)) {
|
|
193
|
-
throw new Error("Link preview URL points to a private host");
|
|
194
|
-
}
|
|
195
|
-
return parsed;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function safeHttpUrl(value: string | null | undefined) {
|
|
199
|
-
if (!value) return null;
|
|
200
|
-
try {
|
|
201
|
-
const parsed = new URL(value);
|
|
202
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
return parsed.toString();
|
|
206
|
-
} catch {
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export const __test__ = {
|
|
212
|
-
assertSafePreviewUrl,
|
|
213
|
-
ipv4FromIpv6Suffix,
|
|
214
|
-
ipv4ToNumber,
|
|
215
|
-
isBlockedAddress,
|
|
216
|
-
isIpv4InRange,
|
|
217
|
-
isPrivateIpv6,
|
|
218
|
-
parseIpv6Parts,
|
|
219
|
-
safeHttpUrl,
|
|
220
|
-
};
|