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
@@ -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
  });