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
@@ -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
  });
@@ -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
  });
@@ -0,0 +1,123 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
+ import { runEffectBackground } from "#/lib/effect-runtime";
5
+ import {
6
+ parseBoundedInteger,
7
+ runRouteEffect,
8
+ sensitiveRequestErrorResponse,
9
+ } from "#/lib/http-effect";
10
+ import {
11
+ streamPeriodDigestEffect,
12
+ type PeriodDigestOptions,
13
+ type PeriodDigestStreamEvent,
14
+ } from "#/lib/period-digest";
15
+
16
+ const encoder = new TextEncoder();
17
+
18
+ function parseBoolean(value: string | null) {
19
+ return value === "true" || value === "1" || value === "yes";
20
+ }
21
+
22
+ function parseOptions(url: URL): PeriodDigestOptions {
23
+ return {
24
+ period: url.searchParams.get("period") ?? undefined,
25
+ since: url.searchParams.get("since") ?? undefined,
26
+ until: url.searchParams.get("until") ?? undefined,
27
+ account: url.searchParams.get("account") ?? undefined,
28
+ includeDms: parseBoolean(url.searchParams.get("includeDms")),
29
+ refresh: parseBoolean(url.searchParams.get("refresh")),
30
+ model: url.searchParams.get("model") === "gpt-5.5" ? "gpt-5.5" : undefined,
31
+ maxTweets: parseBoundedInteger(url.searchParams.get("maxTweets"), {
32
+ max: 500,
33
+ }),
34
+ maxLinks: parseBoundedInteger(url.searchParams.get("maxLinks"), {
35
+ max: 25,
36
+ }),
37
+ };
38
+ }
39
+
40
+ function encodeEvent(event: PeriodDigestStreamEvent) {
41
+ return encoder.encode(`${JSON.stringify(event)}\n`);
42
+ }
43
+
44
+ export const Route = createFileRoute("/api/period-digest")({
45
+ server: {
46
+ handlers: {
47
+ GET: ({ request }) =>
48
+ runRouteEffect(
49
+ Effect.gen(function* () {
50
+ const denied = sensitiveRequestErrorResponse(request);
51
+ if (denied) return denied;
52
+
53
+ yield* maybeAutoUpdateBackupEffect();
54
+ const url = new URL(request.url);
55
+ const options = parseOptions(url);
56
+ let abortDigest: (() => void) | undefined;
57
+
58
+ return new Response(
59
+ new ReadableStream({
60
+ cancel() {
61
+ abortDigest?.();
62
+ },
63
+ start(controller) {
64
+ const abortController = new AbortController();
65
+ let closed = false;
66
+ const close = () => {
67
+ closed = true;
68
+ abortController.abort();
69
+ };
70
+ const closeController = () => {
71
+ request.signal.removeEventListener("abort", onAbort);
72
+ if (!closed) {
73
+ closed = true;
74
+ controller.close();
75
+ }
76
+ };
77
+ const onAbort = () => close();
78
+ request.signal.addEventListener("abort", onAbort, {
79
+ once: true,
80
+ });
81
+ abortDigest = close;
82
+ const enqueue = (event: PeriodDigestStreamEvent) => {
83
+ if (closed) return;
84
+ try {
85
+ controller.enqueue(encodeEvent(event));
86
+ } catch {
87
+ close();
88
+ }
89
+ };
90
+
91
+ runEffectBackground(
92
+ streamPeriodDigestEffect(
93
+ { ...options, signal: abortController.signal },
94
+ { onEvent: enqueue },
95
+ ),
96
+ {
97
+ onSuccess: closeController,
98
+ onFailure: (error) => {
99
+ enqueue({
100
+ type: "error",
101
+ error:
102
+ error instanceof Error
103
+ ? error.message
104
+ : "Digest failed",
105
+ });
106
+ closeController();
107
+ },
108
+ },
109
+ );
110
+ },
111
+ }),
112
+ {
113
+ headers: {
114
+ "cache-control": "no-store",
115
+ "content-type": "application/x-ndjson; charset=utf-8",
116
+ },
117
+ },
118
+ );
119
+ }),
120
+ ),
121
+ },
122
+ },
123
+ });
@@ -1,15 +1,11 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { resolveProfilesForHandles } from "#/lib/profile-resolver";
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 { resolveProfilesForHandlesEffect } from "#/lib/profile-resolver";
13
9
 
14
10
  function parseHandles(url: URL) {
15
11
  const rawValues = [
@@ -28,24 +24,31 @@ function parseHandles(url: URL) {
28
24
  export const Route = createFileRoute("/api/profile-hydrate")({
29
25
  server: {
30
26
  handlers: {
31
- GET: async ({ request }) => {
32
- const url = new URL(request.url);
33
- const handles = parseHandles(url);
34
- if (handles.length === 0) {
35
- return json(
36
- { ok: false, message: "Missing handles" },
37
- { status: 400 },
38
- );
39
- }
27
+ GET: ({ request }) =>
28
+ runRouteEffect(
29
+ Effect.gen(function* () {
30
+ const denied = sensitiveRequestErrorResponse(request);
31
+ if (denied) return denied;
32
+
33
+ const url = new URL(request.url);
34
+ const handles = parseHandles(url);
35
+ if (handles.length === 0) {
36
+ return jsonResponse(
37
+ { ok: false, message: "Missing handles" },
38
+ { status: 400 },
39
+ );
40
+ }
40
41
 
41
- const results = await resolveProfilesForHandles(handles);
42
- return json({
43
- ok: true,
44
- results,
45
- hydratedProfiles: results.filter((result) => result.status === "hit")
46
- .length,
47
- });
48
- },
42
+ const results = yield* resolveProfilesForHandlesEffect(handles);
43
+ return jsonResponse({
44
+ ok: true,
45
+ results,
46
+ hydratedProfiles: results.filter(
47
+ (result) => result.status === "hit",
48
+ ).length,
49
+ });
50
+ }),
51
+ ),
49
52
  },
50
53
  },
51
54
  });
@@ -1,20 +1,20 @@
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
+ parseBoundedInteger,
7
+ runRouteEffect,
8
+ sensitiveRequestErrorResponse,
9
+ } from "#/lib/http-effect";
3
10
  import { queryResource } from "#/lib/queries";
4
11
  import type {
12
+ DmQuery,
5
13
  ReplyFilter,
6
14
  ResourceKind,
7
15
  TimelineQualityFilter,
8
16
  } from "#/lib/types";
9
17
 
10
- function json(data: unknown) {
11
- return new Response(JSON.stringify(data), {
12
- headers: {
13
- "content-type": "application/json",
14
- },
15
- });
16
- }
17
-
18
18
  function parseReplyFilter(value: string | null): ReplyFilter {
19
19
  if (value === "replied" || value === "unreplied") {
20
20
  return value;
@@ -22,69 +22,93 @@ function parseReplyFilter(value: string | null): ReplyFilter {
22
22
  return "all";
23
23
  }
24
24
 
25
- function parseNumber(value: string | null) {
25
+ function parseOptionalNumber(value: string | null) {
26
26
  if (!value) return undefined;
27
27
  const parsed = Number(value);
28
28
  return Number.isFinite(parsed) ? parsed : undefined;
29
29
  }
30
30
 
31
+ function parseDmSort(value: string | null) {
32
+ if (value === "followers" || value === "influence") {
33
+ return "followers";
34
+ }
35
+ return "recent";
36
+ }
37
+
31
38
  function parseQualityFilter(value: string | null): TimelineQualityFilter {
32
39
  return value === "summary" ? "summary" : "all";
33
40
  }
34
41
 
42
+ function parseDmInbox(value: string | null): NonNullable<DmQuery["inbox"]> {
43
+ if (value === "accepted" || value === "requests") return value;
44
+ return "all";
45
+ }
46
+
35
47
  export const Route = createFileRoute("/api/query")({
36
48
  server: {
37
49
  handlers: {
38
- GET: async ({ request }) => {
39
- await maybeAutoUpdateBackup();
40
- const url = new URL(request.url);
41
- const resource = (url.searchParams.get("resource") ??
42
- "home") as ResourceKind;
43
- const baseFilters = {
44
- account: url.searchParams.get("account") ?? undefined,
45
- search: url.searchParams.get("search") ?? undefined,
46
- replyFilter: parseReplyFilter(url.searchParams.get("replyFilter")),
47
- since: url.searchParams.get("since") ?? undefined,
48
- until: url.searchParams.get("until") ?? undefined,
49
- includeReplies: url.searchParams.get("originalsOnly") !== "true",
50
- qualityFilter: parseQualityFilter(
51
- url.searchParams.get("qualityFilter"),
52
- ),
53
- likedOnly: url.searchParams.get("liked") === "true",
54
- bookmarkedOnly: url.searchParams.get("bookmarked") === "true",
55
- limit: parseNumber(url.searchParams.get("limit")) ?? undefined,
56
- };
50
+ GET: ({ request }) =>
51
+ runRouteEffect(
52
+ Effect.gen(function* () {
53
+ const denied = sensitiveRequestErrorResponse(request);
54
+ if (denied) return denied;
57
55
 
58
- if (resource === "dms") {
59
- return json(
60
- queryResource("dms", {
61
- ...baseFilters,
62
- participant: url.searchParams.get("participant") ?? undefined,
63
- minFollowers: parseNumber(url.searchParams.get("minFollowers")),
64
- maxFollowers: parseNumber(url.searchParams.get("maxFollowers")),
65
- minInfluenceScore: parseNumber(
66
- url.searchParams.get("minInfluenceScore"),
56
+ yield* maybeAutoUpdateBackupEffect();
57
+ const url = new URL(request.url);
58
+ const resource = (url.searchParams.get("resource") ??
59
+ "home") as ResourceKind;
60
+ const baseFilters = {
61
+ account: url.searchParams.get("account") ?? undefined,
62
+ search: url.searchParams.get("search") ?? undefined,
63
+ replyFilter: parseReplyFilter(
64
+ url.searchParams.get("replyFilter"),
67
65
  ),
68
- maxInfluenceScore: parseNumber(
69
- url.searchParams.get("maxInfluenceScore"),
66
+ since: url.searchParams.get("since") ?? undefined,
67
+ until: url.searchParams.get("until") ?? undefined,
68
+ includeReplies: url.searchParams.get("originalsOnly") !== "true",
69
+ qualityFilter: parseQualityFilter(
70
+ url.searchParams.get("qualityFilter"),
70
71
  ),
71
- sort:
72
- url.searchParams.get("sort") === "influence"
73
- ? "influence"
74
- : "recent",
75
- conversationId:
76
- url.searchParams.get("conversationId") ?? undefined,
77
- }),
78
- );
79
- }
72
+ likedOnly: url.searchParams.get("liked") === "true",
73
+ bookmarkedOnly: url.searchParams.get("bookmarked") === "true",
74
+ limit: parseBoundedInteger(url.searchParams.get("limit"), {
75
+ max: 200,
76
+ }),
77
+ };
78
+
79
+ if (resource === "dms") {
80
+ return jsonResponse(
81
+ queryResource("dms", {
82
+ ...baseFilters,
83
+ participant: url.searchParams.get("participant") ?? undefined,
84
+ minFollowers: parseOptionalNumber(
85
+ url.searchParams.get("minFollowers"),
86
+ ),
87
+ maxFollowers: parseOptionalNumber(
88
+ url.searchParams.get("maxFollowers"),
89
+ ),
90
+ minInfluenceScore: parseOptionalNumber(
91
+ url.searchParams.get("minInfluenceScore"),
92
+ ),
93
+ maxInfluenceScore: parseOptionalNumber(
94
+ url.searchParams.get("maxInfluenceScore"),
95
+ ),
96
+ sort: parseDmSort(url.searchParams.get("sort")),
97
+ inbox: parseDmInbox(url.searchParams.get("inbox")),
98
+ conversationId:
99
+ url.searchParams.get("conversationId") ?? undefined,
100
+ }),
101
+ );
102
+ }
80
103
 
81
- return json(
82
- queryResource(resource, {
83
- ...baseFilters,
84
- resource,
104
+ return jsonResponse(
105
+ queryResource(resource, {
106
+ ...baseFilters,
107
+ resource,
108
+ }),
109
+ );
85
110
  }),
86
- );
87
- },
111
+ ),
88
112
  },
89
113
  },
90
114
  });
@@ -1,18 +1,26 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { maybeAutoUpdateBackup } from "#/lib/backup";
3
- import { getQueryEnvelope } from "#/lib/queries";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
+ import {
5
+ jsonResponse,
6
+ runRouteEffect,
7
+ sensitiveRequestErrorResponse,
8
+ } from "#/lib/http-effect";
9
+ import { getQueryEnvelopeEffect } from "#/lib/queries";
4
10
 
5
11
  export const Route = createFileRoute("/api/status")({
6
12
  server: {
7
13
  handlers: {
8
- GET: async () => {
9
- await maybeAutoUpdateBackup();
10
- return new Response(JSON.stringify(await getQueryEnvelope()), {
11
- headers: {
12
- "content-type": "application/json",
13
- },
14
- });
15
- },
14
+ GET: ({ request }) =>
15
+ runRouteEffect(
16
+ Effect.gen(function* () {
17
+ const denied = sensitiveRequestErrorResponse(request);
18
+ if (denied) return denied;
19
+
20
+ yield* maybeAutoUpdateBackupEffect();
21
+ return jsonResponse(yield* getQueryEnvelopeEffect());
22
+ }),
23
+ ),
16
24
  },
17
25
  },
18
26
  });