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
|
@@ -1,5 +1,18 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
|
|
7
|
+
import { Effect } from "effect";
|
|
1
8
|
import { getNativeDb } from "./db";
|
|
9
|
+
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
2
10
|
import type { Database } from "./sqlite";
|
|
11
|
+
import {
|
|
12
|
+
__test__ as urlSafetyTest,
|
|
13
|
+
assertSafePreviewUrl,
|
|
14
|
+
isBlockedAddress,
|
|
15
|
+
} from "./url-safety";
|
|
3
16
|
import {
|
|
4
17
|
normalizeUrlExpansionForIndex,
|
|
5
18
|
upsertUrlExpansion,
|
|
@@ -7,6 +20,8 @@ import {
|
|
|
7
20
|
|
|
8
21
|
const FETCH_TIMEOUT_MS = 12_000;
|
|
9
22
|
const MAX_HTML_CHARS = 2_000_000;
|
|
23
|
+
const MAX_REDIRECTS = 4;
|
|
24
|
+
const NO_BODY_STATUS_CODES = new Set([204, 205, 304]);
|
|
10
25
|
|
|
11
26
|
export interface LinkPreviewMetadata {
|
|
12
27
|
url: string;
|
|
@@ -21,7 +36,14 @@ export interface GetLinkPreviewOptions {
|
|
|
21
36
|
shortUrl?: string | null;
|
|
22
37
|
refresh?: boolean;
|
|
23
38
|
fetchImpl?: typeof fetch;
|
|
39
|
+
resolveHost?: (hostname: string) => Promise<string[]>;
|
|
24
40
|
timeoutMs?: number;
|
|
41
|
+
method?: "GET" | "HEAD";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ResolvedAddress {
|
|
45
|
+
address: string;
|
|
46
|
+
family: 4 | 6;
|
|
25
47
|
}
|
|
26
48
|
|
|
27
49
|
type UrlExpansionPreviewRow = {
|
|
@@ -44,13 +66,21 @@ function cleanText(value: string | null | undefined) {
|
|
|
44
66
|
return cleaned.length > 0 ? cleaned : null;
|
|
45
67
|
}
|
|
46
68
|
|
|
69
|
+
function decodeCodePoint(value: number, fallback: string) {
|
|
70
|
+
try {
|
|
71
|
+
return String.fromCodePoint(value);
|
|
72
|
+
} catch {
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
function decodeHtmlEntities(value: string) {
|
|
48
78
|
return value
|
|
49
|
-
.replace(/&#(\d+);/g, (
|
|
50
|
-
|
|
79
|
+
.replace(/&#(\d+);/g, (entity: string, code: string) =>
|
|
80
|
+
decodeCodePoint(Number(code), entity),
|
|
51
81
|
)
|
|
52
|
-
.replace(/&#x([a-f0-9]+);/gi, (
|
|
53
|
-
|
|
82
|
+
.replace(/&#x([a-f0-9]+);/gi, (entity: string, code: string) =>
|
|
83
|
+
decodeCodePoint(Number.parseInt(code, 16), entity),
|
|
54
84
|
)
|
|
55
85
|
.replace(/&/g, "&")
|
|
56
86
|
.replace(/</g, "<")
|
|
@@ -123,6 +153,367 @@ function hostLabel(url: string) {
|
|
|
123
153
|
}
|
|
124
154
|
}
|
|
125
155
|
|
|
156
|
+
function isInjectedFetchAllowed() {
|
|
157
|
+
return process.env.NODE_ENV === "test" || process.env.VITEST === "true";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stripAddressBrackets(value: string) {
|
|
161
|
+
return value.replace(/^\[|\]$/g, "");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function withTimeout<T>(promise: Promise<T>, ms: number) {
|
|
165
|
+
let timer: NodeJS.Timeout | null = null;
|
|
166
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
167
|
+
timer = setTimeout(
|
|
168
|
+
() => reject(new Error("Link preview request timed out")),
|
|
169
|
+
ms,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
173
|
+
if (timer) clearTimeout(timer);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function resolvePublicAddresses(
|
|
178
|
+
hostname: string,
|
|
179
|
+
): Promise<string[]> {
|
|
180
|
+
const normalized = stripAddressBrackets(hostname);
|
|
181
|
+
if (net.isIP(normalized)) return [normalized];
|
|
182
|
+
const records = await lookup(normalized, { all: true, verbatim: true });
|
|
183
|
+
return records.map((record) => record.address);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateResolvedAddresses(addresses: string[]) {
|
|
187
|
+
if (addresses.length === 0) {
|
|
188
|
+
throw new Error("Link preview host did not resolve");
|
|
189
|
+
}
|
|
190
|
+
if (addresses.some(isBlockedAddress)) {
|
|
191
|
+
throw new Error("Link preview URL resolves to a private address");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function resolveSafeAddresses(
|
|
196
|
+
hostname: string,
|
|
197
|
+
resolveHost: (hostname: string) => Promise<string[]>,
|
|
198
|
+
timeoutMs: number,
|
|
199
|
+
): Promise<ResolvedAddress[]> {
|
|
200
|
+
const normalizedHostname = stripAddressBrackets(hostname);
|
|
201
|
+
const addresses = await withTimeout(
|
|
202
|
+
resolveHost(normalizedHostname),
|
|
203
|
+
timeoutMs,
|
|
204
|
+
);
|
|
205
|
+
validateResolvedAddresses(addresses);
|
|
206
|
+
return addresses.map((address) => {
|
|
207
|
+
const normalized = stripAddressBrackets(address);
|
|
208
|
+
const family = net.isIP(normalized);
|
|
209
|
+
if (family !== 4 && family !== 6) {
|
|
210
|
+
throw new Error("Link preview host resolved to an invalid address");
|
|
211
|
+
}
|
|
212
|
+
return { address: normalized, family };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function headersFromIncoming(headers: http.IncomingHttpHeaders): HeadersInit {
|
|
217
|
+
const result = new Headers();
|
|
218
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
219
|
+
if (Array.isArray(value)) {
|
|
220
|
+
for (const item of value) result.append(key, item);
|
|
221
|
+
} else if (typeof value === "string") {
|
|
222
|
+
result.set(key, value);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function decodedResponseBody(response: Response) {
|
|
229
|
+
const body = response.body;
|
|
230
|
+
if (!body) return null;
|
|
231
|
+
const encoding = response.headers
|
|
232
|
+
.get("content-encoding")
|
|
233
|
+
?.split(",")
|
|
234
|
+
.at(-1)
|
|
235
|
+
?.trim()
|
|
236
|
+
.toLowerCase();
|
|
237
|
+
if (!encoding || encoding === "identity") return body;
|
|
238
|
+
const nodeBody = Readable.fromWeb(
|
|
239
|
+
body as Parameters<typeof Readable.fromWeb>[0],
|
|
240
|
+
);
|
|
241
|
+
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
242
|
+
return Readable.toWeb(nodeBody.pipe(createGunzip())) as ReadableStream;
|
|
243
|
+
}
|
|
244
|
+
if (encoding === "br") {
|
|
245
|
+
return Readable.toWeb(
|
|
246
|
+
nodeBody.pipe(createBrotliDecompress()),
|
|
247
|
+
) as ReadableStream;
|
|
248
|
+
}
|
|
249
|
+
if (encoding === "deflate") {
|
|
250
|
+
return Readable.toWeb(nodeBody.pipe(createInflate())) as ReadableStream;
|
|
251
|
+
}
|
|
252
|
+
return body;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function cancelResponseBodyEffect(response: Response) {
|
|
256
|
+
return tryPromise(() => response.body?.cancel() ?? Promise.resolve()).pipe(
|
|
257
|
+
Effect.catchAll(() => Effect.void),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function respondWithResolvedAddress(
|
|
262
|
+
selected: ResolvedAddress,
|
|
263
|
+
lookupOptions: { all?: boolean } | number | undefined,
|
|
264
|
+
callback: (
|
|
265
|
+
error: Error | null,
|
|
266
|
+
address: string | ResolvedAddress[],
|
|
267
|
+
family?: 4 | 6,
|
|
268
|
+
) => void,
|
|
269
|
+
) {
|
|
270
|
+
if (typeof lookupOptions === "object" && lookupOptions?.all) {
|
|
271
|
+
callback(null, [selected]);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
callback(null, selected.address, selected.family);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function nodeSafeFetch(
|
|
278
|
+
url: URL,
|
|
279
|
+
options: {
|
|
280
|
+
addresses: ResolvedAddress[];
|
|
281
|
+
headers: Record<string, string>;
|
|
282
|
+
method: "GET" | "HEAD";
|
|
283
|
+
timeoutMs: number;
|
|
284
|
+
},
|
|
285
|
+
) {
|
|
286
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
287
|
+
|
|
288
|
+
function fetchAddress(address: ResolvedAddress, attemptTimeoutMs: number) {
|
|
289
|
+
return new Promise<Response>((resolve, reject) => {
|
|
290
|
+
let settled = false;
|
|
291
|
+
const finish = (callback: () => void) => {
|
|
292
|
+
if (settled) return;
|
|
293
|
+
settled = true;
|
|
294
|
+
try {
|
|
295
|
+
callback();
|
|
296
|
+
} catch (error) {
|
|
297
|
+
reject(error);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const client = url.protocol === "https:" ? https : http;
|
|
302
|
+
let wallClockTimeout: NodeJS.Timeout | null = null;
|
|
303
|
+
const clearWallClockTimeout = () => {
|
|
304
|
+
if (!wallClockTimeout) return;
|
|
305
|
+
clearTimeout(wallClockTimeout);
|
|
306
|
+
wallClockTimeout = null;
|
|
307
|
+
};
|
|
308
|
+
const setWallClockTimeout = (
|
|
309
|
+
ms: number,
|
|
310
|
+
target: { destroy: (error: Error) => void },
|
|
311
|
+
) => {
|
|
312
|
+
clearWallClockTimeout();
|
|
313
|
+
wallClockTimeout = setTimeout(() => {
|
|
314
|
+
target.destroy(new Error("Link preview request timed out"));
|
|
315
|
+
}, ms);
|
|
316
|
+
};
|
|
317
|
+
const request = client.request(
|
|
318
|
+
url,
|
|
319
|
+
{
|
|
320
|
+
headers: options.headers,
|
|
321
|
+
method: options.method,
|
|
322
|
+
lookup: (_hostname, lookupOptions, callback) => {
|
|
323
|
+
respondWithResolvedAddress(address, lookupOptions, callback);
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
(incoming) => {
|
|
327
|
+
request.setTimeout(0);
|
|
328
|
+
setWallClockTimeout(Math.max(1, deadline - Date.now()), incoming);
|
|
329
|
+
incoming.once("end", clearWallClockTimeout);
|
|
330
|
+
incoming.once("close", clearWallClockTimeout);
|
|
331
|
+
incoming.once("error", clearWallClockTimeout);
|
|
332
|
+
finish(() => {
|
|
333
|
+
const status = incoming.statusCode ?? 200;
|
|
334
|
+
const response = new Response(
|
|
335
|
+
NO_BODY_STATUS_CODES.has(status)
|
|
336
|
+
? null
|
|
337
|
+
: (Readable.toWeb(incoming) as ReadableStream),
|
|
338
|
+
{
|
|
339
|
+
headers: headersFromIncoming(incoming.headers),
|
|
340
|
+
status,
|
|
341
|
+
statusText: incoming.statusMessage,
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
Object.defineProperty(response, "url", {
|
|
345
|
+
value: url.toString(),
|
|
346
|
+
});
|
|
347
|
+
resolve(response);
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
setWallClockTimeout(attemptTimeoutMs, request);
|
|
352
|
+
request.setTimeout(attemptTimeoutMs, () => {
|
|
353
|
+
request.destroy(new Error("Link preview request timed out"));
|
|
354
|
+
});
|
|
355
|
+
request.on("error", (error) => {
|
|
356
|
+
clearWallClockTimeout();
|
|
357
|
+
finish(() => reject(error));
|
|
358
|
+
});
|
|
359
|
+
request.end();
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return options.addresses.reduce<Promise<Response>>(
|
|
364
|
+
(previous, address, index) =>
|
|
365
|
+
previous.catch((error: unknown) => {
|
|
366
|
+
const remainingMs = deadline - Date.now();
|
|
367
|
+
if (remainingMs <= 0) throw error;
|
|
368
|
+
const remainingAddresses = options.addresses.length - index;
|
|
369
|
+
const attemptTimeoutMs = Math.max(
|
|
370
|
+
1,
|
|
371
|
+
Math.ceil(remainingMs / remainingAddresses),
|
|
372
|
+
);
|
|
373
|
+
return fetchAddress(address, attemptTimeoutMs);
|
|
374
|
+
}),
|
|
375
|
+
Promise.reject(new Error("Link preview host did not resolve")),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function safePreviewFetchEffect(
|
|
380
|
+
url: string,
|
|
381
|
+
options: Pick<
|
|
382
|
+
GetLinkPreviewOptions,
|
|
383
|
+
"fetchImpl" | "method" | "resolveHost" | "timeoutMs"
|
|
384
|
+
>,
|
|
385
|
+
) {
|
|
386
|
+
const resolveHost =
|
|
387
|
+
options.resolveHost ??
|
|
388
|
+
(options.fetchImpl
|
|
389
|
+
? null
|
|
390
|
+
: (hostname: string) => resolvePublicAddresses(hostname));
|
|
391
|
+
const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
392
|
+
const method = options.method ?? "GET";
|
|
393
|
+
const deadline = Date.now() + timeoutMs;
|
|
394
|
+
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
|
|
395
|
+
const headers: Record<string, string> = {
|
|
396
|
+
"user-agent":
|
|
397
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 birdclaw/0.4",
|
|
398
|
+
accept:
|
|
399
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
400
|
+
"accept-language": "en-US,en;q=0.9",
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
return Effect.gen(function* () {
|
|
404
|
+
if (options.fetchImpl && !isInjectedFetchAllowed()) {
|
|
405
|
+
return yield* Effect.fail(
|
|
406
|
+
new Error("Custom link preview fetch is only available in tests"),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let currentUrl = url;
|
|
411
|
+
for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
|
|
412
|
+
if (Date.now() >= deadline) {
|
|
413
|
+
return yield* Effect.fail(new Error("Link preview request timed out"));
|
|
414
|
+
}
|
|
415
|
+
let remainingMs = remainingTimeoutMs();
|
|
416
|
+
const parsed = yield* Effect.try({
|
|
417
|
+
try: () => assertSafePreviewUrl(currentUrl),
|
|
418
|
+
catch: (error) => error,
|
|
419
|
+
});
|
|
420
|
+
if (options.fetchImpl && resolveHost) {
|
|
421
|
+
yield* tryPromise(() =>
|
|
422
|
+
resolveSafeAddresses(parsed.hostname, resolveHost, remainingMs),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
if (Date.now() >= deadline) {
|
|
426
|
+
return yield* Effect.fail(new Error("Link preview request timed out"));
|
|
427
|
+
}
|
|
428
|
+
remainingMs = remainingTimeoutMs();
|
|
429
|
+
|
|
430
|
+
const response = options.fetchImpl
|
|
431
|
+
? yield* tryPromise(
|
|
432
|
+
() =>
|
|
433
|
+
options.fetchImpl?.(parsed.toString(), {
|
|
434
|
+
headers,
|
|
435
|
+
method,
|
|
436
|
+
redirect: "manual",
|
|
437
|
+
signal: AbortSignal.timeout(remainingMs),
|
|
438
|
+
}) as Promise<Response>,
|
|
439
|
+
)
|
|
440
|
+
: yield* tryPromise(() =>
|
|
441
|
+
resolveSafeAddresses(
|
|
442
|
+
parsed.hostname,
|
|
443
|
+
options.resolveHost ?? resolvePublicAddresses,
|
|
444
|
+
remainingMs,
|
|
445
|
+
).then((addresses) =>
|
|
446
|
+
Date.now() >= deadline
|
|
447
|
+
? Promise.reject(new Error("Link preview request timed out"))
|
|
448
|
+
: nodeSafeFetch(parsed, {
|
|
449
|
+
addresses,
|
|
450
|
+
headers,
|
|
451
|
+
method,
|
|
452
|
+
timeoutMs: remainingTimeoutMs(),
|
|
453
|
+
}),
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
if (response.status < 300 || response.status >= 400) return response;
|
|
457
|
+
|
|
458
|
+
const location = response.headers.get("location");
|
|
459
|
+
if (!location) return response;
|
|
460
|
+
yield* cancelResponseBodyEffect(response);
|
|
461
|
+
if (redirect === MAX_REDIRECTS) {
|
|
462
|
+
return yield* Effect.fail(
|
|
463
|
+
new Error("Link preview redirected too many times"),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const nextUrl = yield* Effect.try({
|
|
467
|
+
try: () => new URL(location, parsed).toString(),
|
|
468
|
+
catch: (error) => error,
|
|
469
|
+
});
|
|
470
|
+
currentUrl = nextUrl;
|
|
471
|
+
}
|
|
472
|
+
return yield* Effect.fail(
|
|
473
|
+
new Error("Link preview redirected too many times"),
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function readResponseTextEffect(response: Response) {
|
|
479
|
+
const contentLength = Number(response.headers.get("content-length") ?? 0);
|
|
480
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_HTML_CHARS) {
|
|
481
|
+
return tryPromise(() => response.body?.cancel() ?? Promise.resolve()).pipe(
|
|
482
|
+
Effect.flatMap(() =>
|
|
483
|
+
Effect.fail(new Error("Link preview response is too large")),
|
|
484
|
+
),
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
const reader = decodedResponseBody(response)?.getReader();
|
|
488
|
+
if (!reader) return tryPromise(() => response.text());
|
|
489
|
+
const decoder = new TextDecoder();
|
|
490
|
+
let total = 0;
|
|
491
|
+
let text = "";
|
|
492
|
+
return Effect.gen(function* () {
|
|
493
|
+
for (;;) {
|
|
494
|
+
const { done, value } = yield* tryPromise(() => reader.read());
|
|
495
|
+
if (done) break;
|
|
496
|
+
total += value.byteLength;
|
|
497
|
+
if (total > MAX_HTML_CHARS) {
|
|
498
|
+
yield* tryPromise(() =>
|
|
499
|
+
reader.cancel(new Error("Link preview response is too large")),
|
|
500
|
+
);
|
|
501
|
+
return yield* Effect.fail(
|
|
502
|
+
new Error("Link preview response is too large"),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
text += decoder.decode(value, { stream: true });
|
|
506
|
+
}
|
|
507
|
+
return text + decoder.decode();
|
|
508
|
+
}).pipe(
|
|
509
|
+
Effect.ensuring(
|
|
510
|
+
Effect.sync(() => {
|
|
511
|
+
reader.releaseLock();
|
|
512
|
+
}),
|
|
513
|
+
),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
126
517
|
function youtubeThumbnail(url: string) {
|
|
127
518
|
try {
|
|
128
519
|
const parsed = new URL(url);
|
|
@@ -183,52 +574,59 @@ export function extractLinkPreviewMetadata(
|
|
|
183
574
|
};
|
|
184
575
|
}
|
|
185
576
|
|
|
186
|
-
export
|
|
577
|
+
export function fetchLinkPreviewMetadataEffect(
|
|
187
578
|
url: string,
|
|
188
|
-
options: Pick<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
accept:
|
|
196
|
-
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
197
|
-
"accept-language": "en-US,en;q=0.9",
|
|
198
|
-
} satisfies HeadersInit;
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
const response = await fetchImpl(url, {
|
|
202
|
-
headers,
|
|
203
|
-
redirect: "follow",
|
|
204
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
205
|
-
});
|
|
579
|
+
options: Pick<
|
|
580
|
+
GetLinkPreviewOptions,
|
|
581
|
+
"fetchImpl" | "resolveHost" | "timeoutMs"
|
|
582
|
+
> = {},
|
|
583
|
+
): Effect.Effect<LinkPreviewMetadata> {
|
|
584
|
+
return Effect.gen(function* () {
|
|
585
|
+
const response = yield* safePreviewFetchEffect(url, options);
|
|
206
586
|
const finalUrl = response.url || url;
|
|
207
587
|
const contentType = response.headers.get("content-type") ?? "";
|
|
208
588
|
if (!response.ok) {
|
|
209
|
-
|
|
589
|
+
yield* cancelResponseBodyEffect(response);
|
|
590
|
+
return yield* Effect.fail(new Error(`HTTP ${response.status}`));
|
|
210
591
|
}
|
|
211
592
|
if (contentType.toLowerCase().startsWith("image/")) {
|
|
593
|
+
yield* cancelResponseBodyEffect(response);
|
|
212
594
|
return {
|
|
213
595
|
url: finalUrl,
|
|
214
596
|
title: hostLabel(finalUrl),
|
|
215
597
|
description: null,
|
|
216
598
|
imageUrl: finalUrl,
|
|
217
599
|
siteName: hostLabel(finalUrl),
|
|
218
|
-
};
|
|
600
|
+
} satisfies LinkPreviewMetadata;
|
|
219
601
|
}
|
|
220
|
-
const
|
|
221
|
-
return
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
602
|
+
const content = yield* readResponseTextEffect(response);
|
|
603
|
+
return yield* Effect.try({
|
|
604
|
+
try: () =>
|
|
605
|
+
extractLinkPreviewMetadata(content.slice(0, MAX_HTML_CHARS), finalUrl),
|
|
606
|
+
catch: (error) => error,
|
|
607
|
+
});
|
|
608
|
+
}).pipe(
|
|
609
|
+
Effect.catchAll((error) =>
|
|
610
|
+
Effect.succeed({
|
|
611
|
+
url,
|
|
612
|
+
title: hostLabel(url),
|
|
613
|
+
description: null,
|
|
614
|
+
imageUrl: youtubeThumbnail(url),
|
|
615
|
+
siteName: hostLabel(url),
|
|
616
|
+
error: error instanceof Error ? error.message : String(error),
|
|
617
|
+
}),
|
|
618
|
+
),
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function fetchLinkPreviewMetadata(
|
|
623
|
+
url: string,
|
|
624
|
+
options: Pick<
|
|
625
|
+
GetLinkPreviewOptions,
|
|
626
|
+
"fetchImpl" | "resolveHost" | "timeoutMs"
|
|
627
|
+
> = {},
|
|
628
|
+
): Promise<LinkPreviewMetadata> {
|
|
629
|
+
return runEffectPromise(fetchLinkPreviewMetadataEffect(url, options));
|
|
232
630
|
}
|
|
233
631
|
|
|
234
632
|
function readCachedPreview(
|
|
@@ -267,18 +665,29 @@ function readCachedPreview(
|
|
|
267
665
|
}
|
|
268
666
|
|
|
269
667
|
function hasUsefulPreview(row: UrlExpansionPreviewRow) {
|
|
668
|
+
if (row.status !== "hit") return false;
|
|
669
|
+
try {
|
|
670
|
+
assertSafePreviewUrl(row.final_url || row.expanded_url || row.short_url);
|
|
671
|
+
if (row.image_url) assertSafePreviewUrl(row.image_url);
|
|
672
|
+
} catch {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
270
675
|
return Boolean(
|
|
271
676
|
row.title || row.description || row.image_url || row.site_name,
|
|
272
677
|
);
|
|
273
678
|
}
|
|
274
679
|
|
|
275
680
|
function rowToPreview(row: UrlExpansionPreviewRow): LinkPreviewMetadata {
|
|
276
|
-
const url =
|
|
681
|
+
const url = assertSafePreviewUrl(
|
|
682
|
+
row.final_url || row.expanded_url || row.short_url,
|
|
683
|
+
).toString();
|
|
277
684
|
return {
|
|
278
685
|
url,
|
|
279
686
|
title: row.title ?? null,
|
|
280
687
|
description: row.description ?? null,
|
|
281
|
-
imageUrl: row.image_url
|
|
688
|
+
imageUrl: row.image_url
|
|
689
|
+
? assertSafePreviewUrl(row.image_url).toString()
|
|
690
|
+
: null,
|
|
282
691
|
siteName: row.site_name ?? null,
|
|
283
692
|
...(row.error ? { error: row.error } : {}),
|
|
284
693
|
};
|
|
@@ -310,25 +719,36 @@ function persistPreview(
|
|
|
310
719
|
);
|
|
311
720
|
}
|
|
312
721
|
|
|
313
|
-
export
|
|
722
|
+
export function getOrFetchLinkPreviewEffect(
|
|
314
723
|
url: string,
|
|
315
724
|
options: GetLinkPreviewOptions = {},
|
|
316
|
-
):
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
725
|
+
): Effect.Effect<LinkPreviewMetadata> {
|
|
726
|
+
return Effect.gen(function* () {
|
|
727
|
+
const db = getNativeDb({ seedDemoData: false });
|
|
728
|
+
const cached = readCachedPreview(db, url, options.shortUrl);
|
|
729
|
+
if (cached && hasUsefulPreview(cached) && !options.refresh) {
|
|
730
|
+
return rowToPreview(cached);
|
|
731
|
+
}
|
|
322
732
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
733
|
+
const preview = yield* fetchLinkPreviewMetadataEffect(
|
|
734
|
+
cached?.final_url || cached?.expanded_url || url,
|
|
735
|
+
options,
|
|
736
|
+
);
|
|
737
|
+
persistPreview(db, url, options.shortUrl, cached, preview);
|
|
738
|
+
return preview;
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export function getOrFetchLinkPreview(
|
|
743
|
+
url: string,
|
|
744
|
+
options: GetLinkPreviewOptions = {},
|
|
745
|
+
): Promise<LinkPreviewMetadata> {
|
|
746
|
+
return runEffectPromise(getOrFetchLinkPreviewEffect(url, options));
|
|
329
747
|
}
|
|
330
748
|
|
|
331
749
|
export const __test__ = {
|
|
750
|
+
...urlSafetyTest,
|
|
332
751
|
decodeHtmlEntities,
|
|
752
|
+
respondWithResolvedAddress,
|
|
333
753
|
youtubeThumbnail,
|
|
334
754
|
};
|