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.
Files changed (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  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 +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -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'
@@ -17,9 +18,11 @@ import { Route as DmsRouteImport } from './routes/dms'
17
18
  import { Route as BookmarksRouteImport } from './routes/bookmarks'
18
19
  import { Route as BlocksRouteImport } from './routes/blocks'
19
20
  import { Route as IndexRouteImport } from './routes/index'
21
+ import { Route as ApiSyncRouteImport } from './routes/api/sync'
20
22
  import { Route as ApiStatusRouteImport } from './routes/api/status'
21
23
  import { Route as ApiQueryRouteImport } from './routes/api/query'
22
24
  import { Route as ApiProfileHydrateRouteImport } from './routes/api/profile-hydrate'
25
+ import { Route as ApiPeriodDigestRouteImport } from './routes/api/period-digest'
23
26
  import { Route as ApiLinkPreviewRouteImport } from './routes/api/link-preview'
24
27
  import { Route as ApiLinkInsightsRouteImport } from './routes/api/link-insights'
25
28
  import { Route as ApiInboxRouteImport } from './routes/api/inbox'
@@ -28,6 +31,11 @@ import { Route as ApiBlocksRouteImport } from './routes/api/blocks'
28
31
  import { Route as ApiAvatarRouteImport } from './routes/api/avatar'
29
32
  import { Route as ApiActionRouteImport } from './routes/api/action'
30
33
 
34
+ const TodayRoute = TodayRouteImport.update({
35
+ id: '/today',
36
+ path: '/today',
37
+ getParentRoute: () => rootRouteImport,
38
+ } as any)
31
39
  const MentionsRoute = MentionsRouteImport.update({
32
40
  id: '/mentions',
33
41
  path: '/mentions',
@@ -68,6 +76,11 @@ const IndexRoute = IndexRouteImport.update({
68
76
  path: '/',
69
77
  getParentRoute: () => rootRouteImport,
70
78
  } as any)
79
+ const ApiSyncRoute = ApiSyncRouteImport.update({
80
+ id: '/api/sync',
81
+ path: '/api/sync',
82
+ getParentRoute: () => rootRouteImport,
83
+ } as any)
71
84
  const ApiStatusRoute = ApiStatusRouteImport.update({
72
85
  id: '/api/status',
73
86
  path: '/api/status',
@@ -83,6 +96,11 @@ const ApiProfileHydrateRoute = ApiProfileHydrateRouteImport.update({
83
96
  path: '/api/profile-hydrate',
84
97
  getParentRoute: () => rootRouteImport,
85
98
  } as any)
99
+ const ApiPeriodDigestRoute = ApiPeriodDigestRouteImport.update({
100
+ id: '/api/period-digest',
101
+ path: '/api/period-digest',
102
+ getParentRoute: () => rootRouteImport,
103
+ } as any)
86
104
  const ApiLinkPreviewRoute = ApiLinkPreviewRouteImport.update({
87
105
  id: '/api/link-preview',
88
106
  path: '/api/link-preview',
@@ -128,6 +146,7 @@ export interface FileRoutesByFullPath {
128
146
  '/likes': typeof LikesRoute
129
147
  '/links': typeof LinksRoute
130
148
  '/mentions': typeof MentionsRoute
149
+ '/today': typeof TodayRoute
131
150
  '/api/action': typeof ApiActionRoute
132
151
  '/api/avatar': typeof ApiAvatarRoute
133
152
  '/api/blocks': typeof ApiBlocksRoute
@@ -135,9 +154,11 @@ export interface FileRoutesByFullPath {
135
154
  '/api/inbox': typeof ApiInboxRoute
136
155
  '/api/link-insights': typeof ApiLinkInsightsRoute
137
156
  '/api/link-preview': typeof ApiLinkPreviewRoute
157
+ '/api/period-digest': typeof ApiPeriodDigestRoute
138
158
  '/api/profile-hydrate': typeof ApiProfileHydrateRoute
139
159
  '/api/query': typeof ApiQueryRoute
140
160
  '/api/status': typeof ApiStatusRoute
161
+ '/api/sync': typeof ApiSyncRoute
141
162
  }
142
163
  export interface FileRoutesByTo {
143
164
  '/': typeof IndexRoute
@@ -148,6 +169,7 @@ export interface FileRoutesByTo {
148
169
  '/likes': typeof LikesRoute
149
170
  '/links': typeof LinksRoute
150
171
  '/mentions': typeof MentionsRoute
172
+ '/today': typeof TodayRoute
151
173
  '/api/action': typeof ApiActionRoute
152
174
  '/api/avatar': typeof ApiAvatarRoute
153
175
  '/api/blocks': typeof ApiBlocksRoute
@@ -155,9 +177,11 @@ export interface FileRoutesByTo {
155
177
  '/api/inbox': typeof ApiInboxRoute
156
178
  '/api/link-insights': typeof ApiLinkInsightsRoute
157
179
  '/api/link-preview': typeof ApiLinkPreviewRoute
180
+ '/api/period-digest': typeof ApiPeriodDigestRoute
158
181
  '/api/profile-hydrate': typeof ApiProfileHydrateRoute
159
182
  '/api/query': typeof ApiQueryRoute
160
183
  '/api/status': typeof ApiStatusRoute
184
+ '/api/sync': typeof ApiSyncRoute
161
185
  }
162
186
  export interface FileRoutesById {
163
187
  __root__: typeof rootRouteImport
@@ -169,6 +193,7 @@ export interface FileRoutesById {
169
193
  '/likes': typeof LikesRoute
170
194
  '/links': typeof LinksRoute
171
195
  '/mentions': typeof MentionsRoute
196
+ '/today': typeof TodayRoute
172
197
  '/api/action': typeof ApiActionRoute
173
198
  '/api/avatar': typeof ApiAvatarRoute
174
199
  '/api/blocks': typeof ApiBlocksRoute
@@ -176,9 +201,11 @@ export interface FileRoutesById {
176
201
  '/api/inbox': typeof ApiInboxRoute
177
202
  '/api/link-insights': typeof ApiLinkInsightsRoute
178
203
  '/api/link-preview': typeof ApiLinkPreviewRoute
204
+ '/api/period-digest': typeof ApiPeriodDigestRoute
179
205
  '/api/profile-hydrate': typeof ApiProfileHydrateRoute
180
206
  '/api/query': typeof ApiQueryRoute
181
207
  '/api/status': typeof ApiStatusRoute
208
+ '/api/sync': typeof ApiSyncRoute
182
209
  }
183
210
  export interface FileRouteTypes {
184
211
  fileRoutesByFullPath: FileRoutesByFullPath
@@ -191,6 +218,7 @@ export interface FileRouteTypes {
191
218
  | '/likes'
192
219
  | '/links'
193
220
  | '/mentions'
221
+ | '/today'
194
222
  | '/api/action'
195
223
  | '/api/avatar'
196
224
  | '/api/blocks'
@@ -198,9 +226,11 @@ export interface FileRouteTypes {
198
226
  | '/api/inbox'
199
227
  | '/api/link-insights'
200
228
  | '/api/link-preview'
229
+ | '/api/period-digest'
201
230
  | '/api/profile-hydrate'
202
231
  | '/api/query'
203
232
  | '/api/status'
233
+ | '/api/sync'
204
234
  fileRoutesByTo: FileRoutesByTo
205
235
  to:
206
236
  | '/'
@@ -211,6 +241,7 @@ export interface FileRouteTypes {
211
241
  | '/likes'
212
242
  | '/links'
213
243
  | '/mentions'
244
+ | '/today'
214
245
  | '/api/action'
215
246
  | '/api/avatar'
216
247
  | '/api/blocks'
@@ -218,9 +249,11 @@ export interface FileRouteTypes {
218
249
  | '/api/inbox'
219
250
  | '/api/link-insights'
220
251
  | '/api/link-preview'
252
+ | '/api/period-digest'
221
253
  | '/api/profile-hydrate'
222
254
  | '/api/query'
223
255
  | '/api/status'
256
+ | '/api/sync'
224
257
  id:
225
258
  | '__root__'
226
259
  | '/'
@@ -231,6 +264,7 @@ export interface FileRouteTypes {
231
264
  | '/likes'
232
265
  | '/links'
233
266
  | '/mentions'
267
+ | '/today'
234
268
  | '/api/action'
235
269
  | '/api/avatar'
236
270
  | '/api/blocks'
@@ -238,9 +272,11 @@ export interface FileRouteTypes {
238
272
  | '/api/inbox'
239
273
  | '/api/link-insights'
240
274
  | '/api/link-preview'
275
+ | '/api/period-digest'
241
276
  | '/api/profile-hydrate'
242
277
  | '/api/query'
243
278
  | '/api/status'
279
+ | '/api/sync'
244
280
  fileRoutesById: FileRoutesById
245
281
  }
246
282
  export interface RootRouteChildren {
@@ -252,6 +288,7 @@ export interface RootRouteChildren {
252
288
  LikesRoute: typeof LikesRoute
253
289
  LinksRoute: typeof LinksRoute
254
290
  MentionsRoute: typeof MentionsRoute
291
+ TodayRoute: typeof TodayRoute
255
292
  ApiActionRoute: typeof ApiActionRoute
256
293
  ApiAvatarRoute: typeof ApiAvatarRoute
257
294
  ApiBlocksRoute: typeof ApiBlocksRoute
@@ -259,13 +296,22 @@ export interface RootRouteChildren {
259
296
  ApiInboxRoute: typeof ApiInboxRoute
260
297
  ApiLinkInsightsRoute: typeof ApiLinkInsightsRoute
261
298
  ApiLinkPreviewRoute: typeof ApiLinkPreviewRoute
299
+ ApiPeriodDigestRoute: typeof ApiPeriodDigestRoute
262
300
  ApiProfileHydrateRoute: typeof ApiProfileHydrateRoute
263
301
  ApiQueryRoute: typeof ApiQueryRoute
264
302
  ApiStatusRoute: typeof ApiStatusRoute
303
+ ApiSyncRoute: typeof ApiSyncRoute
265
304
  }
266
305
 
267
306
  declare module '@tanstack/react-router' {
268
307
  interface FileRoutesByPath {
308
+ '/today': {
309
+ id: '/today'
310
+ path: '/today'
311
+ fullPath: '/today'
312
+ preLoaderRoute: typeof TodayRouteImport
313
+ parentRoute: typeof rootRouteImport
314
+ }
269
315
  '/mentions': {
270
316
  id: '/mentions'
271
317
  path: '/mentions'
@@ -322,6 +368,13 @@ declare module '@tanstack/react-router' {
322
368
  preLoaderRoute: typeof IndexRouteImport
323
369
  parentRoute: typeof rootRouteImport
324
370
  }
371
+ '/api/sync': {
372
+ id: '/api/sync'
373
+ path: '/api/sync'
374
+ fullPath: '/api/sync'
375
+ preLoaderRoute: typeof ApiSyncRouteImport
376
+ parentRoute: typeof rootRouteImport
377
+ }
325
378
  '/api/status': {
326
379
  id: '/api/status'
327
380
  path: '/api/status'
@@ -343,6 +396,13 @@ declare module '@tanstack/react-router' {
343
396
  preLoaderRoute: typeof ApiProfileHydrateRouteImport
344
397
  parentRoute: typeof rootRouteImport
345
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
+ }
346
406
  '/api/link-preview': {
347
407
  id: '/api/link-preview'
348
408
  path: '/api/link-preview'
@@ -404,6 +464,7 @@ const rootRouteChildren: RootRouteChildren = {
404
464
  LikesRoute: LikesRoute,
405
465
  LinksRoute: LinksRoute,
406
466
  MentionsRoute: MentionsRoute,
467
+ TodayRoute: TodayRoute,
407
468
  ApiActionRoute: ApiActionRoute,
408
469
  ApiAvatarRoute: ApiAvatarRoute,
409
470
  ApiBlocksRoute: ApiBlocksRoute,
@@ -411,9 +472,11 @@ const rootRouteChildren: RootRouteChildren = {
411
472
  ApiInboxRoute: ApiInboxRoute,
412
473
  ApiLinkInsightsRoute: ApiLinkInsightsRoute,
413
474
  ApiLinkPreviewRoute: ApiLinkPreviewRoute,
475
+ ApiPeriodDigestRoute: ApiPeriodDigestRoute,
414
476
  ApiProfileHydrateRoute: ApiProfileHydrateRoute,
415
477
  ApiQueryRoute: ApiQueryRoute,
416
478
  ApiStatusRoute: ApiStatusRoute,
479
+ ApiSyncRoute: ApiSyncRoute,
417
480
  }
418
481
  export const routeTree = rootRouteImport
419
482
  ._addFileChildren(rootRouteChildren)
@@ -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
  });