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.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -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 +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -9,6 +9,7 @@
9
9
  // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
10
 
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
+ import { Route as TodayRouteImport } from './routes/today'
12
13
  import { Route as MentionsRouteImport } from './routes/mentions'
13
14
  import { Route as LinksRouteImport } from './routes/links'
14
15
  import { Route as LikesRouteImport } from './routes/likes'
@@ -21,6 +22,7 @@ import { Route as ApiSyncRouteImport } from './routes/api/sync'
21
22
  import { Route as ApiStatusRouteImport } from './routes/api/status'
22
23
  import { Route as ApiQueryRouteImport } from './routes/api/query'
23
24
  import { Route as ApiProfileHydrateRouteImport } from './routes/api/profile-hydrate'
25
+ import { Route as ApiPeriodDigestRouteImport } from './routes/api/period-digest'
24
26
  import { Route as ApiLinkPreviewRouteImport } from './routes/api/link-preview'
25
27
  import { Route as ApiLinkInsightsRouteImport } from './routes/api/link-insights'
26
28
  import { Route as ApiInboxRouteImport } from './routes/api/inbox'
@@ -29,6 +31,11 @@ import { Route as ApiBlocksRouteImport } from './routes/api/blocks'
29
31
  import { Route as ApiAvatarRouteImport } from './routes/api/avatar'
30
32
  import { Route as ApiActionRouteImport } from './routes/api/action'
31
33
 
34
+ const TodayRoute = TodayRouteImport.update({
35
+ id: '/today',
36
+ path: '/today',
37
+ getParentRoute: () => rootRouteImport,
38
+ } as any)
32
39
  const MentionsRoute = MentionsRouteImport.update({
33
40
  id: '/mentions',
34
41
  path: '/mentions',
@@ -89,6 +96,11 @@ const ApiProfileHydrateRoute = ApiProfileHydrateRouteImport.update({
89
96
  path: '/api/profile-hydrate',
90
97
  getParentRoute: () => rootRouteImport,
91
98
  } as any)
99
+ const ApiPeriodDigestRoute = ApiPeriodDigestRouteImport.update({
100
+ id: '/api/period-digest',
101
+ path: '/api/period-digest',
102
+ getParentRoute: () => rootRouteImport,
103
+ } as any)
92
104
  const ApiLinkPreviewRoute = ApiLinkPreviewRouteImport.update({
93
105
  id: '/api/link-preview',
94
106
  path: '/api/link-preview',
@@ -134,6 +146,7 @@ export interface FileRoutesByFullPath {
134
146
  '/likes': typeof LikesRoute
135
147
  '/links': typeof LinksRoute
136
148
  '/mentions': typeof MentionsRoute
149
+ '/today': typeof TodayRoute
137
150
  '/api/action': typeof ApiActionRoute
138
151
  '/api/avatar': typeof ApiAvatarRoute
139
152
  '/api/blocks': typeof ApiBlocksRoute
@@ -141,6 +154,7 @@ export interface FileRoutesByFullPath {
141
154
  '/api/inbox': typeof ApiInboxRoute
142
155
  '/api/link-insights': typeof ApiLinkInsightsRoute
143
156
  '/api/link-preview': typeof ApiLinkPreviewRoute
157
+ '/api/period-digest': typeof ApiPeriodDigestRoute
144
158
  '/api/profile-hydrate': typeof ApiProfileHydrateRoute
145
159
  '/api/query': typeof ApiQueryRoute
146
160
  '/api/status': typeof ApiStatusRoute
@@ -155,6 +169,7 @@ export interface FileRoutesByTo {
155
169
  '/likes': typeof LikesRoute
156
170
  '/links': typeof LinksRoute
157
171
  '/mentions': typeof MentionsRoute
172
+ '/today': typeof TodayRoute
158
173
  '/api/action': typeof ApiActionRoute
159
174
  '/api/avatar': typeof ApiAvatarRoute
160
175
  '/api/blocks': typeof ApiBlocksRoute
@@ -162,6 +177,7 @@ export interface FileRoutesByTo {
162
177
  '/api/inbox': typeof ApiInboxRoute
163
178
  '/api/link-insights': typeof ApiLinkInsightsRoute
164
179
  '/api/link-preview': typeof ApiLinkPreviewRoute
180
+ '/api/period-digest': typeof ApiPeriodDigestRoute
165
181
  '/api/profile-hydrate': typeof ApiProfileHydrateRoute
166
182
  '/api/query': typeof ApiQueryRoute
167
183
  '/api/status': typeof ApiStatusRoute
@@ -177,6 +193,7 @@ export interface FileRoutesById {
177
193
  '/likes': typeof LikesRoute
178
194
  '/links': typeof LinksRoute
179
195
  '/mentions': typeof MentionsRoute
196
+ '/today': typeof TodayRoute
180
197
  '/api/action': typeof ApiActionRoute
181
198
  '/api/avatar': typeof ApiAvatarRoute
182
199
  '/api/blocks': typeof ApiBlocksRoute
@@ -184,6 +201,7 @@ export interface FileRoutesById {
184
201
  '/api/inbox': typeof ApiInboxRoute
185
202
  '/api/link-insights': typeof ApiLinkInsightsRoute
186
203
  '/api/link-preview': typeof ApiLinkPreviewRoute
204
+ '/api/period-digest': typeof ApiPeriodDigestRoute
187
205
  '/api/profile-hydrate': typeof ApiProfileHydrateRoute
188
206
  '/api/query': typeof ApiQueryRoute
189
207
  '/api/status': typeof ApiStatusRoute
@@ -200,6 +218,7 @@ export interface FileRouteTypes {
200
218
  | '/likes'
201
219
  | '/links'
202
220
  | '/mentions'
221
+ | '/today'
203
222
  | '/api/action'
204
223
  | '/api/avatar'
205
224
  | '/api/blocks'
@@ -207,6 +226,7 @@ export interface FileRouteTypes {
207
226
  | '/api/inbox'
208
227
  | '/api/link-insights'
209
228
  | '/api/link-preview'
229
+ | '/api/period-digest'
210
230
  | '/api/profile-hydrate'
211
231
  | '/api/query'
212
232
  | '/api/status'
@@ -221,6 +241,7 @@ export interface FileRouteTypes {
221
241
  | '/likes'
222
242
  | '/links'
223
243
  | '/mentions'
244
+ | '/today'
224
245
  | '/api/action'
225
246
  | '/api/avatar'
226
247
  | '/api/blocks'
@@ -228,6 +249,7 @@ export interface FileRouteTypes {
228
249
  | '/api/inbox'
229
250
  | '/api/link-insights'
230
251
  | '/api/link-preview'
252
+ | '/api/period-digest'
231
253
  | '/api/profile-hydrate'
232
254
  | '/api/query'
233
255
  | '/api/status'
@@ -242,6 +264,7 @@ export interface FileRouteTypes {
242
264
  | '/likes'
243
265
  | '/links'
244
266
  | '/mentions'
267
+ | '/today'
245
268
  | '/api/action'
246
269
  | '/api/avatar'
247
270
  | '/api/blocks'
@@ -249,6 +272,7 @@ export interface FileRouteTypes {
249
272
  | '/api/inbox'
250
273
  | '/api/link-insights'
251
274
  | '/api/link-preview'
275
+ | '/api/period-digest'
252
276
  | '/api/profile-hydrate'
253
277
  | '/api/query'
254
278
  | '/api/status'
@@ -264,6 +288,7 @@ export interface RootRouteChildren {
264
288
  LikesRoute: typeof LikesRoute
265
289
  LinksRoute: typeof LinksRoute
266
290
  MentionsRoute: typeof MentionsRoute
291
+ TodayRoute: typeof TodayRoute
267
292
  ApiActionRoute: typeof ApiActionRoute
268
293
  ApiAvatarRoute: typeof ApiAvatarRoute
269
294
  ApiBlocksRoute: typeof ApiBlocksRoute
@@ -271,6 +296,7 @@ export interface RootRouteChildren {
271
296
  ApiInboxRoute: typeof ApiInboxRoute
272
297
  ApiLinkInsightsRoute: typeof ApiLinkInsightsRoute
273
298
  ApiLinkPreviewRoute: typeof ApiLinkPreviewRoute
299
+ ApiPeriodDigestRoute: typeof ApiPeriodDigestRoute
274
300
  ApiProfileHydrateRoute: typeof ApiProfileHydrateRoute
275
301
  ApiQueryRoute: typeof ApiQueryRoute
276
302
  ApiStatusRoute: typeof ApiStatusRoute
@@ -279,6 +305,13 @@ export interface RootRouteChildren {
279
305
 
280
306
  declare module '@tanstack/react-router' {
281
307
  interface FileRoutesByPath {
308
+ '/today': {
309
+ id: '/today'
310
+ path: '/today'
311
+ fullPath: '/today'
312
+ preLoaderRoute: typeof TodayRouteImport
313
+ parentRoute: typeof rootRouteImport
314
+ }
282
315
  '/mentions': {
283
316
  id: '/mentions'
284
317
  path: '/mentions'
@@ -363,6 +396,13 @@ declare module '@tanstack/react-router' {
363
396
  preLoaderRoute: typeof ApiProfileHydrateRouteImport
364
397
  parentRoute: typeof rootRouteImport
365
398
  }
399
+ '/api/period-digest': {
400
+ id: '/api/period-digest'
401
+ path: '/api/period-digest'
402
+ fullPath: '/api/period-digest'
403
+ preLoaderRoute: typeof ApiPeriodDigestRouteImport
404
+ parentRoute: typeof rootRouteImport
405
+ }
366
406
  '/api/link-preview': {
367
407
  id: '/api/link-preview'
368
408
  path: '/api/link-preview'
@@ -424,6 +464,7 @@ const rootRouteChildren: RootRouteChildren = {
424
464
  LikesRoute: LikesRoute,
425
465
  LinksRoute: LinksRoute,
426
466
  MentionsRoute: MentionsRoute,
467
+ TodayRoute: TodayRoute,
427
468
  ApiActionRoute: ApiActionRoute,
428
469
  ApiAvatarRoute: ApiAvatarRoute,
429
470
  ApiBlocksRoute: ApiBlocksRoute,
@@ -431,6 +472,7 @@ const rootRouteChildren: RootRouteChildren = {
431
472
  ApiInboxRoute: ApiInboxRoute,
432
473
  ApiLinkInsightsRoute: ApiLinkInsightsRoute,
433
474
  ApiLinkPreviewRoute: ApiLinkPreviewRoute,
475
+ ApiPeriodDigestRoute: ApiPeriodDigestRoute,
434
476
  ApiProfileHydrateRoute: ApiProfileHydrateRoute,
435
477
  ApiQueryRoute: ApiQueryRoute,
436
478
  ApiStatusRoute: ApiStatusRoute,
@@ -1,10 +1,21 @@
1
1
  import { TanStackDevtools } from "@tanstack/react-devtools";
2
- import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
2
+ import {
3
+ createRootRoute,
4
+ HeadContent,
5
+ Scripts,
6
+ useRouterState,
7
+ } from "@tanstack/react-router";
3
8
  import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
4
9
  import type { ReactNode } from "react";
5
10
  import { AppNav } from "#/components/AppNav";
6
11
  import { ThemeProvider, themeScript } from "#/lib/theme";
7
- import { bodyClass, mainColumnClass, siteShellClass } from "#/lib/ui";
12
+ import {
13
+ bodyClass,
14
+ mainColumnClass,
15
+ mainColumnDmClass,
16
+ siteShellClass,
17
+ siteShellDmClass,
18
+ } from "#/lib/ui";
8
19
 
9
20
  import appCss from "../styles.css?url";
10
21
 
@@ -42,6 +53,11 @@ function NotFoundView() {
42
53
  }
43
54
 
44
55
  function RootDocument({ children }: { children: ReactNode }) {
56
+ const pathname = useRouterState({
57
+ select: (state) => state.location.pathname,
58
+ });
59
+ const messagesMode = pathname.startsWith("/dms");
60
+
45
61
  return (
46
62
  <html lang="en" suppressHydrationWarning>
47
63
  <head>
@@ -50,9 +66,13 @@ function RootDocument({ children }: { children: ReactNode }) {
50
66
  </head>
51
67
  <body className={bodyClass}>
52
68
  <ThemeProvider>
53
- <div className={siteShellClass}>
54
- <AppNav />
55
- <main className={mainColumnClass}>{children}</main>
69
+ <div className={messagesMode ? siteShellDmClass : siteShellClass}>
70
+ <AppNav compact={messagesMode} />
71
+ <main
72
+ className={messagesMode ? mainColumnDmClass : mainColumnClass}
73
+ >
74
+ {children}
75
+ </main>
56
76
  </div>
57
77
  </ThemeProvider>
58
78
  <TanStackDevtools
@@ -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
  });