birdclaw 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -1,8 +1,24 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { addBlock, removeBlock, syncBlocks } from "#/lib/blocks";
3
- import { scoreInbox } from "#/lib/inbox";
4
- import { addMute, removeMute } from "#/lib/mutes";
5
- import { createDmReply, createPost, createTweetReply } from "#/lib/queries";
2
+ import { Effect } from "effect";
3
+ import {
4
+ addBlockEffect,
5
+ removeBlockEffect,
6
+ syncBlocksEffect,
7
+ } from "#/lib/blocks";
8
+ import {
9
+ jsonResponse,
10
+ parseBoundedInteger,
11
+ requestJsonEffect,
12
+ runRouteEffect,
13
+ sensitiveRequestErrorResponse,
14
+ } from "#/lib/http-effect";
15
+ import { scoreInboxEffect } from "#/lib/inbox";
16
+ import { addMuteEffect, removeMuteEffect } from "#/lib/mutes";
17
+ import {
18
+ createDmReplyEffect,
19
+ createPostEffect,
20
+ createTweetReplyEffect,
21
+ } from "#/lib/queries";
6
22
  import type { ActionsTransport } from "#/lib/config";
7
23
  import type { InboxKind } from "#/lib/types";
8
24
 
@@ -14,85 +30,118 @@ function parseActionsTransport(
14
30
  : undefined;
15
31
  }
16
32
 
33
+ function actionErrorMessage(error: unknown) {
34
+ if (
35
+ typeof error === "object" &&
36
+ error !== null &&
37
+ "cause" in error &&
38
+ error.cause instanceof Error
39
+ ) {
40
+ return error.cause.message;
41
+ }
42
+ if (error instanceof Error) {
43
+ return error.message;
44
+ }
45
+ return String(error);
46
+ }
47
+
17
48
  export const Route = createFileRoute("/api/action")({
18
49
  server: {
19
50
  handlers: {
20
- POST: async ({ request }) => {
21
- const body = (await request.json()) as Record<string, string>;
22
- const transport = parseActionsTransport(body.transport);
23
- let result: unknown;
51
+ POST: ({ request }) =>
52
+ runRouteEffect(
53
+ Effect.gen(function* () {
54
+ const denied = sensitiveRequestErrorResponse(request);
55
+ if (denied) return denied;
56
+
57
+ const body =
58
+ yield* requestJsonEffect<Record<string, string>>(request);
59
+ const transport = parseActionsTransport(body.transport);
60
+ let result: unknown;
24
61
 
25
- if (body.kind === "post") {
26
- result = await createPost(
27
- body.accountId || "acct_primary",
28
- body.text || "",
29
- );
30
- } else if (body.kind === "replyTweet") {
31
- result = await createTweetReply(
32
- body.accountId || "acct_primary",
33
- body.tweetId || "",
34
- body.text || "",
35
- );
36
- } else if (body.kind === "replyDm") {
37
- result = await createDmReply(
38
- body.conversationId || "",
39
- body.text || "",
40
- );
41
- } else if (body.kind === "scoreInbox") {
42
- result = await scoreInbox({
43
- kind: ((body.scoreKind as InboxKind) || "mixed") as InboxKind,
44
- limit: body.limit ? Number(body.limit) : 8,
45
- });
46
- } else if (body.kind === "blockProfile") {
47
- result = await addBlock(
48
- body.accountId || "acct_primary",
49
- body.query || "",
50
- {
51
- transport,
52
- },
53
- );
54
- } else if (body.kind === "unblockProfile") {
55
- result = await removeBlock(
56
- body.accountId || "acct_primary",
57
- body.query || "",
58
- {
59
- transport,
60
- },
61
- );
62
- } else if (body.kind === "muteProfile") {
63
- result = await addMute(
64
- body.accountId || "acct_primary",
65
- body.query || "",
66
- {
67
- transport,
68
- },
69
- );
70
- } else if (body.kind === "unmuteProfile") {
71
- result = await removeMute(
72
- body.accountId || "acct_primary",
73
- body.query || "",
74
- {
75
- transport,
76
- },
77
- );
78
- } else if (body.kind === "syncBlocks") {
79
- result = await syncBlocks(body.accountId || "acct_primary");
80
- } else {
81
- return new Response(
82
- JSON.stringify({ ok: false, message: "Unknown action kind" }),
83
- {
84
- status: 400,
85
- headers: { "content-type": "application/json" },
86
- },
87
- );
88
- }
62
+ if (body.kind === "post") {
63
+ result = yield* createPostEffect(
64
+ body.accountId || "acct_primary",
65
+ body.text || "",
66
+ );
67
+ } else if (body.kind === "replyTweet") {
68
+ result = yield* createTweetReplyEffect(
69
+ body.accountId || "acct_primary",
70
+ body.tweetId || "",
71
+ body.text || "",
72
+ );
73
+ } else if (body.kind === "replyDm") {
74
+ result = yield* createDmReplyEffect(
75
+ body.conversationId || "",
76
+ body.text || "",
77
+ );
78
+ } else if (body.kind === "scoreInbox") {
79
+ result = yield* scoreInboxEffect({
80
+ kind: ((body.scoreKind as InboxKind) || "mixed") as InboxKind,
81
+ account: body.account,
82
+ limit: parseBoundedInteger(body.limit, {
83
+ defaultValue: 8,
84
+ max: 20,
85
+ }),
86
+ });
87
+ } else if (body.kind === "blockProfile") {
88
+ result = yield* addBlockEffect(
89
+ body.accountId || "acct_primary",
90
+ body.query || "",
91
+ {
92
+ transport,
93
+ },
94
+ );
95
+ } else if (body.kind === "unblockProfile") {
96
+ result = yield* removeBlockEffect(
97
+ body.accountId || "acct_primary",
98
+ body.query || "",
99
+ {
100
+ transport,
101
+ },
102
+ );
103
+ } else if (body.kind === "muteProfile") {
104
+ result = yield* addMuteEffect(
105
+ body.accountId || "acct_primary",
106
+ body.query || "",
107
+ {
108
+ transport,
109
+ },
110
+ );
111
+ } else if (body.kind === "unmuteProfile") {
112
+ result = yield* removeMuteEffect(
113
+ body.accountId || "acct_primary",
114
+ body.query || "",
115
+ {
116
+ transport,
117
+ },
118
+ );
119
+ } else if (body.kind === "syncBlocks") {
120
+ result = yield* syncBlocksEffect(
121
+ body.accountId || "acct_primary",
122
+ );
123
+ } else {
124
+ return jsonResponse(
125
+ { ok: false, message: "Unknown action kind" },
126
+ { status: 400 },
127
+ );
128
+ }
89
129
 
90
- return new Response(JSON.stringify(result), {
91
- headers: {
92
- "content-type": "application/json",
93
- },
94
- });
95
- },
130
+ return jsonResponse(result);
131
+ }).pipe(
132
+ Effect.catchAll((error) =>
133
+ Effect.succeed(
134
+ jsonResponse(
135
+ {
136
+ ok: false,
137
+ message: actionErrorMessage(error),
138
+ },
139
+ { status: 500 },
140
+ ),
141
+ ),
142
+ ),
143
+ ),
144
+ ),
96
145
  },
97
146
  },
98
147
  });
@@ -1,41 +1,50 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { readCachedAvatar } from "#/lib/avatar-cache";
2
+ import { Effect } from "effect";
3
+ import { readCachedAvatarEffect } from "#/lib/avatar-cache";
4
+ import {
5
+ jsonResponse,
6
+ runRouteEffect,
7
+ sensitiveRequestErrorResponse,
8
+ } from "#/lib/http-effect";
3
9
 
4
10
  export const Route = createFileRoute("/api/avatar")({
5
11
  server: {
6
12
  handlers: {
7
- GET: async ({ request }) => {
8
- const url = new URL(request.url);
9
- const profileId = url.searchParams.get("profileId")?.trim();
13
+ GET: ({ request }) =>
14
+ runRouteEffect(
15
+ Effect.gen(function* () {
16
+ const sensitiveError = sensitiveRequestErrorResponse(request);
17
+ if (sensitiveError) return sensitiveError;
10
18
 
11
- if (!profileId) {
12
- return new Response(
13
- JSON.stringify({ ok: false, message: "Missing profileId" }),
14
- {
15
- status: 400,
16
- headers: { "content-type": "application/json" },
17
- },
18
- );
19
- }
19
+ const url = new URL(request.url);
20
+ const profileId = url.searchParams.get("profileId")?.trim();
20
21
 
21
- const avatar = await readCachedAvatar(profileId);
22
- if (!avatar) {
23
- return new Response(
24
- JSON.stringify({ ok: false, message: "Avatar not found" }),
25
- {
26
- status: 404,
27
- headers: { "content-type": "application/json" },
28
- },
29
- );
30
- }
22
+ if (!profileId) {
23
+ return jsonResponse(
24
+ { ok: false, message: "Missing profileId" },
25
+ { status: 400 },
26
+ );
27
+ }
31
28
 
32
- return new Response(new Uint8Array(avatar.buffer), {
33
- headers: {
34
- "cache-control": "public, max-age=86400, immutable",
35
- "content-type": avatar.contentType,
36
- },
37
- });
38
- },
29
+ const avatar = yield* readCachedAvatarEffect(profileId).pipe(
30
+ Effect.catchAll(() => Effect.succeed(null)),
31
+ );
32
+ if (!avatar) {
33
+ return jsonResponse(
34
+ { ok: false, message: "Avatar not found" },
35
+ { status: 404 },
36
+ );
37
+ }
38
+
39
+ return new Response(new Uint8Array(avatar.buffer), {
40
+ headers: {
41
+ "cache-control": "public, max-age=86400, immutable",
42
+ "content-type": avatar.contentType,
43
+ "x-content-type-options": "nosniff",
44
+ },
45
+ });
46
+ }),
47
+ ),
39
48
  },
40
49
  },
41
50
  });
@@ -1,32 +1,35 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
2
3
  import { getBlocksResponse } from "#/lib/blocks";
3
-
4
- function parseNumber(value: string | null) {
5
- if (!value) return undefined;
6
- const parsed = Number(value);
7
- return Number.isFinite(parsed) ? parsed : undefined;
8
- }
4
+ import {
5
+ jsonResponse,
6
+ parseBoundedInteger,
7
+ runRouteEffect,
8
+ sensitiveRequestErrorResponse,
9
+ } from "#/lib/http-effect";
9
10
 
10
11
  export const Route = createFileRoute("/api/blocks")({
11
12
  server: {
12
13
  handlers: {
13
- GET: ({ request }) => {
14
- const url = new URL(request.url);
15
- return new Response(
16
- JSON.stringify(
17
- getBlocksResponse({
18
- accountId: url.searchParams.get("account") ?? undefined,
19
- search: url.searchParams.get("search") ?? undefined,
20
- limit: parseNumber(url.searchParams.get("limit")) ?? 12,
21
- }),
22
- ),
23
- {
24
- headers: {
25
- "content-type": "application/json",
26
- },
27
- },
28
- );
29
- },
14
+ GET: ({ request }) =>
15
+ runRouteEffect(
16
+ Effect.sync(() => {
17
+ const denied = sensitiveRequestErrorResponse(request);
18
+ if (denied) return denied;
19
+
20
+ const url = new URL(request.url);
21
+ return jsonResponse(
22
+ getBlocksResponse({
23
+ accountId: url.searchParams.get("account") ?? undefined,
24
+ search: url.searchParams.get("search") ?? undefined,
25
+ limit: parseBoundedInteger(url.searchParams.get("limit"), {
26
+ defaultValue: 12,
27
+ max: 50,
28
+ }),
29
+ }),
30
+ );
31
+ }),
32
+ ),
30
33
  },
31
34
  },
32
35
  });
@@ -1,5 +1,10 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { maybeAutoUpdateBackup } from "#/lib/backup";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
+ import {
5
+ runRouteEffect,
6
+ sensitiveRequestErrorResponse,
7
+ } from "#/lib/http-effect";
3
8
  import { getTweetConversation } from "#/lib/queries";
4
9
 
5
10
  function json(data: unknown, status = 200) {
@@ -14,21 +19,27 @@ function json(data: unknown, status = 200) {
14
19
  export const Route = createFileRoute("/api/conversation")({
15
20
  server: {
16
21
  handlers: {
17
- GET: async ({ request }) => {
18
- await maybeAutoUpdateBackup();
19
- const url = new URL(request.url);
20
- const tweetId = url.searchParams.get("tweetId")?.trim();
21
- if (!tweetId) {
22
- return json({ ok: false, error: "Missing tweetId" }, 400);
23
- }
22
+ GET: ({ request }) =>
23
+ runRouteEffect(
24
+ Effect.gen(function* () {
25
+ const denied = sensitiveRequestErrorResponse(request);
26
+ if (denied) return denied;
24
27
 
25
- const conversation = getTweetConversation(tweetId);
26
- if (!conversation) {
27
- return json({ ok: false, error: "Tweet not found" }, 404);
28
- }
28
+ yield* maybeAutoUpdateBackupEffect();
29
+ const url = new URL(request.url);
30
+ const tweetId = url.searchParams.get("tweetId")?.trim();
31
+ if (!tweetId) {
32
+ return json({ ok: false, error: "Missing tweetId" }, 400);
33
+ }
29
34
 
30
- return json({ ok: true, ...conversation });
31
- },
35
+ const conversation = getTweetConversation(tweetId);
36
+ if (!conversation) {
37
+ return json({ ok: false, error: "Tweet not found" }, 404);
38
+ }
39
+
40
+ return json({ ok: true, ...conversation });
41
+ }),
42
+ ),
32
43
  },
33
44
  },
34
45
  });
@@ -0,0 +1,24 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
3
+ import { getLiveDataSourcesEffect } from "#/lib/data-sources";
4
+ import {
5
+ jsonResponse,
6
+ runRouteEffect,
7
+ sensitiveRequestErrorResponse,
8
+ } from "#/lib/http-effect";
9
+
10
+ export const Route = createFileRoute("/api/data-sources")({
11
+ server: {
12
+ handlers: {
13
+ GET: ({ request }) =>
14
+ runRouteEffect(
15
+ Effect.gen(function* () {
16
+ const denied = sensitiveRequestErrorResponse(request);
17
+ if (denied) return denied;
18
+
19
+ return jsonResponse(yield* getLiveDataSourcesEffect());
20
+ }),
21
+ ),
22
+ },
23
+ },
24
+ });
@@ -1,5 +1,11 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { maybeAutoUpdateBackup } from "#/lib/backup";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
+ import {
5
+ jsonResponse,
6
+ runRouteEffect,
7
+ sensitiveRequestErrorResponse,
8
+ } from "#/lib/http-effect";
3
9
  import { listInboxItems } from "#/lib/inbox";
4
10
  import type { InboxKind } from "#/lib/types";
5
11
 
@@ -12,26 +18,26 @@ function parseNumber(value: string | null) {
12
18
  export const Route = createFileRoute("/api/inbox")({
13
19
  server: {
14
20
  handlers: {
15
- GET: async ({ request }) => {
16
- await maybeAutoUpdateBackup();
17
- const url = new URL(request.url);
18
- const kind = (url.searchParams.get("kind") ?? "mixed") as InboxKind;
19
- return new Response(
20
- JSON.stringify(
21
- listInboxItems({
22
- kind: kind === "mentions" || kind === "dms" ? kind : "mixed",
23
- minScore: parseNumber(url.searchParams.get("minScore")),
24
- hideLowSignal: url.searchParams.get("hideLowSignal") === "1",
25
- limit: parseNumber(url.searchParams.get("limit")) ?? 20,
26
- }),
27
- ),
28
- {
29
- headers: {
30
- "content-type": "application/json",
31
- },
32
- },
33
- );
34
- },
21
+ GET: ({ request }) =>
22
+ runRouteEffect(
23
+ Effect.gen(function* () {
24
+ const denied = sensitiveRequestErrorResponse(request);
25
+ if (denied) return denied;
26
+
27
+ yield* maybeAutoUpdateBackupEffect();
28
+ const url = new URL(request.url);
29
+ const kind = (url.searchParams.get("kind") ?? "mixed") as InboxKind;
30
+ return jsonResponse(
31
+ listInboxItems({
32
+ kind: kind === "mentions" || kind === "dms" ? kind : "mixed",
33
+ account: url.searchParams.get("account") ?? undefined,
34
+ minScore: parseNumber(url.searchParams.get("minScore")),
35
+ hideLowSignal: url.searchParams.get("hideLowSignal") === "1",
36
+ limit: parseNumber(url.searchParams.get("limit")) ?? 20,
37
+ }),
38
+ );
39
+ }),
40
+ ),
35
41
  },
36
42
  },
37
43
  });
@@ -1,6 +1,12 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { maybeAutoUpdateBackup } from "#/lib/backup";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
3
4
  import { getNativeDb } from "#/lib/db";
5
+ import {
6
+ jsonResponse,
7
+ runRouteEffect,
8
+ sensitiveRequestErrorResponse,
9
+ } from "#/lib/http-effect";
4
10
  import { getLinkInsights } from "#/lib/link-insights";
5
11
  import type {
6
12
  LinkInsightKind,
@@ -9,14 +15,6 @@ import type {
9
15
  LinkInsightSource,
10
16
  } from "#/lib/types";
11
17
 
12
- function json(data: unknown) {
13
- return new Response(JSON.stringify(data), {
14
- headers: {
15
- "content-type": "application/json",
16
- },
17
- });
18
- }
19
-
20
18
  function parseKind(value: string | null): LinkInsightKind {
21
19
  return value === "videos" ? "videos" : "links";
22
20
  }
@@ -57,23 +55,31 @@ function parseNumber(value: string | null) {
57
55
  export const Route = createFileRoute("/api/link-insights")({
58
56
  server: {
59
57
  handlers: {
60
- GET: async ({ request }) => {
61
- await maybeAutoUpdateBackup();
62
- getNativeDb();
63
- const url = new URL(request.url);
64
- return json(
65
- getLinkInsights({
66
- kind: parseKind(url.searchParams.get("kind")),
67
- range: parseRange(url.searchParams.get("range")),
68
- sort: parseSort(url.searchParams.get("sort")),
69
- source: parseSource(url.searchParams.get("source")),
70
- since: url.searchParams.get("since") ?? undefined,
71
- until: url.searchParams.get("until") ?? undefined,
72
- limit: parseNumber(url.searchParams.get("limit")),
73
- commentsLimit: parseNumber(url.searchParams.get("commentsLimit")),
58
+ GET: ({ request }) =>
59
+ runRouteEffect(
60
+ Effect.gen(function* () {
61
+ const denied = sensitiveRequestErrorResponse(request);
62
+ if (denied) return denied;
63
+
64
+ yield* maybeAutoUpdateBackupEffect();
65
+ getNativeDb();
66
+ const url = new URL(request.url);
67
+ return jsonResponse(
68
+ getLinkInsights({
69
+ kind: parseKind(url.searchParams.get("kind")),
70
+ range: parseRange(url.searchParams.get("range")),
71
+ sort: parseSort(url.searchParams.get("sort")),
72
+ source: parseSource(url.searchParams.get("source")),
73
+ since: url.searchParams.get("since") ?? undefined,
74
+ until: url.searchParams.get("until") ?? undefined,
75
+ limit: parseNumber(url.searchParams.get("limit")),
76
+ commentsLimit: parseNumber(
77
+ url.searchParams.get("commentsLimit"),
78
+ ),
79
+ }),
80
+ );
74
81
  }),
75
- );
76
- },
82
+ ),
77
83
  },
78
84
  },
79
85
  });
@@ -1,15 +1,11 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { getOrFetchLinkPreview } from "#/lib/link-preview-metadata";
3
-
4
- function json(data: unknown, init?: ResponseInit) {
5
- return new Response(JSON.stringify(data), {
6
- ...init,
7
- headers: {
8
- "content-type": "application/json",
9
- ...init?.headers,
10
- },
11
- });
12
- }
2
+ import { Effect } from "effect";
3
+ import {
4
+ jsonResponse,
5
+ runRouteEffect,
6
+ sensitiveRequestErrorResponse,
7
+ } from "#/lib/http-effect";
8
+ import { getOrFetchLinkPreviewEffect } from "#/lib/link-preview-metadata";
13
9
 
14
10
  function parseUrl(value: string | null) {
15
11
  if (!value) return null;
@@ -27,17 +23,25 @@ function parseUrl(value: string | null) {
27
23
  export const Route = createFileRoute("/api/link-preview")({
28
24
  server: {
29
25
  handlers: {
30
- GET: async ({ request }) => {
31
- const url = new URL(request.url);
32
- const previewUrl = parseUrl(url.searchParams.get("url"));
33
- const shortUrl = parseUrl(url.searchParams.get("shortUrl"));
34
- if (!previewUrl) {
35
- return json({ ok: false, message: "Missing url" }, { status: 400 });
36
- }
26
+ GET: ({ request }) =>
27
+ runRouteEffect(
28
+ Effect.gen(function* () {
29
+ const denied = sensitiveRequestErrorResponse(request);
30
+ if (denied) return denied;
31
+
32
+ const url = new URL(request.url);
33
+ const previewUrl = parseUrl(url.searchParams.get("url"));
34
+ if (!previewUrl) {
35
+ return jsonResponse(
36
+ { ok: false, message: "Missing url" },
37
+ { status: 400 },
38
+ );
39
+ }
37
40
 
38
- const preview = await getOrFetchLinkPreview(previewUrl, { shortUrl });
39
- return json({ ok: true, preview });
40
- },
41
+ const preview = yield* getOrFetchLinkPreviewEffect(previewUrl);
42
+ return jsonResponse({ ok: true, preview });
43
+ }),
44
+ ),
41
45
  },
42
46
  },
43
47
  });