birdclaw 0.8.0 → 0.8.2

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 (46) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +5 -8
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/components/ConversationThread.tsx +9 -4
  5. package/src/components/DmWorkspace.tsx +26 -9
  6. package/src/components/EmbeddedTweetCard.tsx +9 -4
  7. package/src/components/FloatingPreview.tsx +382 -0
  8. package/src/components/InboxCard.tsx +6 -4
  9. package/src/components/MarkdownViewer.tsx +59 -100
  10. package/src/components/ProfilePreview.tsx +39 -81
  11. package/src/components/SmartTimestamp.tsx +72 -0
  12. package/src/components/TimelineCard.tsx +24 -6
  13. package/src/components/TweetArticleCard.tsx +66 -0
  14. package/src/components/TweetRichText.tsx +33 -2
  15. package/src/components/useDebouncedValue.ts +12 -0
  16. package/src/components/useTimelineRouteData.ts +149 -47
  17. package/src/lib/api-client.ts +117 -2
  18. package/src/lib/archive-finder.ts +38 -0
  19. package/src/lib/authored-live.ts +2 -62
  20. package/src/lib/bird.ts +32 -1
  21. package/src/lib/client-cache.ts +109 -0
  22. package/src/lib/db.ts +57 -4
  23. package/src/lib/mention-threads-live.ts +2 -1
  24. package/src/lib/mentions-live.ts +2 -46
  25. package/src/lib/ndjson-stream.ts +106 -0
  26. package/src/lib/period-digest.ts +184 -24
  27. package/src/lib/present.ts +99 -1
  28. package/src/lib/profile-analysis.ts +1 -1
  29. package/src/lib/queries.ts +68 -6
  30. package/src/lib/sqlite.ts +5 -2
  31. package/src/lib/timeline-collections-live.ts +2 -1
  32. package/src/lib/timeline-live.ts +2 -1
  33. package/src/lib/tweet-render.ts +78 -0
  34. package/src/lib/tweet-search-live.ts +2 -1
  35. package/src/lib/types.ts +8 -0
  36. package/src/lib/ui.ts +1 -1
  37. package/src/router.tsx +1 -1
  38. package/src/routes/__root.tsx +0 -13
  39. package/src/routes/api/period-digest.tsx +21 -69
  40. package/src/routes/api/profile-analysis.tsx +22 -77
  41. package/src/routes/api/search-discussion.tsx +19 -75
  42. package/src/routes/api/status.tsx +3 -1
  43. package/src/routes/dms.tsx +40 -22
  44. package/src/routes/links.tsx +75 -9
  45. package/src/routes/today.tsx +67 -11
  46. package/vite.config.ts +0 -2
@@ -2,13 +2,111 @@ export function formatCompactNumber(value: number) {
2
2
  return new Intl.NumberFormat("en", { notation: "compact" }).format(value);
3
3
  }
4
4
 
5
+ const SECOND_MS = 1_000;
6
+ const MINUTE_MS = 60 * SECOND_MS;
7
+ const HOUR_MS = 60 * MINUTE_MS;
8
+ const DAY_MS = 24 * HOUR_MS;
9
+
10
+ const timeFormatter = new Intl.DateTimeFormat("en", {
11
+ hour: "numeric",
12
+ minute: "2-digit",
13
+ });
14
+
15
+ const weekdayFormatter = new Intl.DateTimeFormat("en", {
16
+ weekday: "long",
17
+ });
18
+
19
+ const sameYearDateFormatter = new Intl.DateTimeFormat("en", {
20
+ month: "short",
21
+ day: "numeric",
22
+ });
23
+
24
+ const otherYearDateFormatter = new Intl.DateTimeFormat("en", {
25
+ year: "numeric",
26
+ month: "short",
27
+ day: "numeric",
28
+ });
29
+
30
+ const exactTimestampFormatter = new Intl.DateTimeFormat("en", {
31
+ year: "numeric",
32
+ month: "long",
33
+ day: "numeric",
34
+ hour: "numeric",
35
+ minute: "2-digit",
36
+ second: "2-digit",
37
+ timeZoneName: "short",
38
+ });
39
+
40
+ function parseTimestamp(value: string) {
41
+ const date = new Date(value);
42
+ return Number.isNaN(date.getTime()) ? null : date;
43
+ }
44
+
45
+ function localCalendarDay(date: Date) {
46
+ return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
47
+ }
48
+
49
+ function formatCalendarTimestamp(date: Date, now: Date) {
50
+ const time = timeFormatter.format(date);
51
+ const dayDifference = localCalendarDay(now) - localCalendarDay(date);
52
+
53
+ if (dayDifference === 1) {
54
+ return `Yesterday at ${time}`;
55
+ }
56
+
57
+ if (dayDifference >= 2 && dayDifference <= 6) {
58
+ return `${weekdayFormatter.format(date)} at ${time}`;
59
+ }
60
+
61
+ const dateFormatter =
62
+ date.getFullYear() === now.getFullYear()
63
+ ? sameYearDateFormatter
64
+ : otherYearDateFormatter;
65
+ return `${dateFormatter.format(date)} at ${time}`;
66
+ }
67
+
5
68
  export function formatShortTimestamp(value: string) {
69
+ const date = parseTimestamp(value);
70
+ if (!date) return value;
71
+
6
72
  return new Intl.DateTimeFormat("en", {
7
73
  hour: "numeric",
8
74
  minute: "2-digit",
9
75
  month: "short",
10
76
  day: "numeric",
11
- }).format(new Date(value));
77
+ }).format(date);
78
+ }
79
+
80
+ export function formatExactTimestamp(value: string) {
81
+ const date = parseTimestamp(value);
82
+ return date ? exactTimestampFormatter.format(date) : value;
83
+ }
84
+
85
+ export function formatSmartTimestamp(value: string, nowValue = Date.now()) {
86
+ const date = parseTimestamp(value);
87
+ const now = new Date(nowValue);
88
+ if (!date || Number.isNaN(now.getTime())) return value;
89
+
90
+ const elapsed = now.getTime() - date.getTime();
91
+ if (elapsed >= -45 * SECOND_MS && elapsed < 45 * SECOND_MS) {
92
+ return "just now";
93
+ }
94
+
95
+ if (elapsed < 0) {
96
+ return formatCalendarTimestamp(date, now);
97
+ }
98
+
99
+ if (elapsed < HOUR_MS) {
100
+ const minutes = Math.max(1, Math.floor(elapsed / MINUTE_MS));
101
+ return `${minutes} min ago`;
102
+ }
103
+
104
+ if (elapsed < DAY_MS) {
105
+ const hours = Math.max(1, Math.floor(elapsed / HOUR_MS));
106
+ return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
107
+ }
108
+
109
+ return formatCalendarTimestamp(date, now);
12
110
  }
13
111
 
14
112
  export function getInitials(value: string) {
@@ -407,7 +407,7 @@ function mergeXurlTweetsIntoLocalStore(
407
407
  replyToId,
408
408
  Number(tweet.public_metrics?.like_count ?? 0),
409
409
  countTweetMedia(tweet),
410
- JSON.stringify(tweet.entities ?? {}),
410
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
411
411
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
412
412
  quotedTweetId,
413
413
  );
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Effect } from "effect";
3
3
  import type { Database } from "./sqlite";
4
- import { findArchivesEffect } from "./archive-finder";
4
+ import { findArchivesCachedEffect } from "./archive-finder";
5
5
  import { getDb, getNativeDb } from "./db";
6
6
  import { runEffectPromise, tryPromise } from "./effect-runtime";
7
7
  import { fetchProfileAffiliations } from "./profile-affiliations";
@@ -701,10 +701,9 @@ function getAccountProfileMeta(
701
701
  | undefined;
702
702
  }
703
703
 
704
- export function getQueryEnvelopeEffect(): Effect.Effect<
705
- QueryEnvelope,
706
- unknown
707
- > {
704
+ export function getQueryEnvelopeEffect({
705
+ includeArchives = true,
706
+ }: { includeArchives?: boolean } = {}): Effect.Effect<QueryEnvelope, unknown> {
708
707
  return Effect.gen(function* () {
709
708
  const db = yield* trySync(() => getDb());
710
709
  const nativeDb = yield* trySync(() => getNativeDb());
@@ -736,7 +735,9 @@ export function getQueryEnvelopeEffect(): Effect.Effect<
736
735
  .orderBy("name", "asc")
737
736
  .execute(),
738
737
  ),
739
- archives: findArchivesEffect(),
738
+ archives: includeArchives
739
+ ? findArchivesCachedEffect()
740
+ : Effect.succeed([]),
740
741
  transport: getTransportStatusEffect(),
741
742
  });
742
743
 
@@ -1286,6 +1287,67 @@ function getTweetById(
1286
1287
  );
1287
1288
  }
1288
1289
 
1290
+ export function getTweetsByIds(
1291
+ tweetIds: string[],
1292
+ accountId?: string,
1293
+ ): EmbeddedTweet[] {
1294
+ const db = getNativeDb();
1295
+ const scopedAccountId =
1296
+ accountId && accountId !== "all" ? accountId : undefined;
1297
+ const urlExpansionCache: UrlExpansionCache = new Map();
1298
+ const profileByHandleCache: ProfileByHandleCache = new Map();
1299
+ const resolveProfileByHandle = (handle: string) =>
1300
+ getProfileByHandle(db, profileByHandleCache, handle);
1301
+ const seen = new Set<string>();
1302
+ const tweets: EmbeddedTweet[] = [];
1303
+
1304
+ for (const tweetId of tweetIds) {
1305
+ const normalized = tweetId.trim().replace(/^tweet_/, "");
1306
+ if (!normalized || seen.has(normalized)) continue;
1307
+ seen.add(normalized);
1308
+ if (
1309
+ scopedAccountId &&
1310
+ !db
1311
+ .prepare(
1312
+ `
1313
+ select 1
1314
+ from tweets tweet
1315
+ where tweet.id = ?
1316
+ and (
1317
+ tweet.account_id = ?
1318
+ or exists (
1319
+ select 1
1320
+ from tweet_account_edges edge
1321
+ where edge.account_id = ?
1322
+ and edge.tweet_id = tweet.id
1323
+ )
1324
+ or exists (
1325
+ select 1
1326
+ from tweet_collections collection
1327
+ where collection.account_id = ?
1328
+ and collection.tweet_id = tweet.id
1329
+ )
1330
+ )
1331
+ limit 1
1332
+ `,
1333
+ )
1334
+ .get(normalized, scopedAccountId, scopedAccountId, scopedAccountId)
1335
+ ) {
1336
+ continue;
1337
+ }
1338
+ const tweet = getTweetById(
1339
+ db,
1340
+ urlExpansionCache,
1341
+ normalized,
1342
+ resolveProfileByHandle,
1343
+ scopedAccountId,
1344
+ );
1345
+ if (tweet) tweets.push(tweet);
1346
+ }
1347
+
1348
+ return tweets;
1349
+ }
1350
+
1289
1351
  function listTweetDescendants(
1290
1352
  db: Database,
1291
1353
  urlExpansionCache: UrlExpansionCache,
package/src/lib/sqlite.ts CHANGED
@@ -5,6 +5,7 @@ export type Database = NativeSqliteDatabase;
5
5
  type DatabaseOptions = {
6
6
  readonly?: boolean;
7
7
  fileMustExist?: boolean;
8
+ timeout?: number;
8
9
  };
9
10
 
10
11
  type PragmaOptions = {
@@ -16,6 +17,8 @@ type RunResult = {
16
17
  lastInsertRowid: number;
17
18
  };
18
19
 
20
+ export const SQLITE_BUSY_TIMEOUT_MS = 30_000;
21
+
19
22
  function bindArgs(parameters: unknown[]) {
20
23
  if (parameters.length === 1 && Array.isArray(parameters[0])) {
21
24
  return parameters[0];
@@ -87,7 +90,7 @@ export class NativeSqliteDatabase {
87
90
  constructor(path: string, options: DatabaseOptions = {}) {
88
91
  this.db = new DatabaseSync(path, {
89
92
  readOnly: options.readonly,
90
- timeout: 5000,
93
+ timeout: options.timeout ?? SQLITE_BUSY_TIMEOUT_MS,
91
94
  });
92
95
  }
93
96
 
@@ -123,7 +126,7 @@ export class NativeSqliteDatabase {
123
126
  return (...args: TArgs) => {
124
127
  const nested = this.db.isTransaction;
125
128
  const savepoint = `__birdclaw_tx_${++this.transactionDepth}`;
126
- this.exec(nested ? `savepoint ${savepoint}` : "begin");
129
+ this.exec(nested ? `savepoint ${savepoint}` : "begin immediate");
127
130
  try {
128
131
  const result = fn(...args);
129
132
  this.exec(nested ? `release ${savepoint}` : "commit");
@@ -8,6 +8,7 @@ import { getNativeDb } from "./db";
8
8
  import { runEffectPromise, tryPromise } from "./effect-runtime";
9
9
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
10
10
  import { readSyncCache, writeSyncCache } from "./sync-cache";
11
+ import { tweetEntitiesFromXurl } from "./tweet-render";
11
12
  import type {
12
13
  XurlMentionData,
13
14
  XurlMentionsResponse,
@@ -310,7 +311,7 @@ function mergeTimelineCollectionIntoLocalStore(
310
311
  countTweetMedia(tweet),
311
312
  bookmarked,
312
313
  liked,
313
- JSON.stringify(tweet.entities ?? {}),
314
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
314
315
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
315
316
  quotedTweetId,
316
317
  );
@@ -5,6 +5,7 @@ import { getNativeDb } from "./db";
5
5
  import { runEffectPromise } from "./effect-runtime";
6
6
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
7
  import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ import { tweetEntitiesFromXurl } from "./tweet-render";
8
9
  import type {
9
10
  XurlMediaItem,
10
11
  XurlMentionUser,
@@ -244,7 +245,7 @@ function mergeHomeTimelineIntoLocalStore(
244
245
  replyToId,
245
246
  Number(tweet.public_metrics?.like_count ?? 0),
246
247
  countTweetMedia(tweet),
247
- JSON.stringify(tweet.entities ?? {}),
248
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
248
249
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
249
250
  quotedTweetId,
250
251
  );
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ TweetArticle,
2
3
  TweetEntities,
3
4
  TweetHashtagEntity,
4
5
  TweetMentionEntity,
@@ -57,6 +58,34 @@ export function displayUrlForLink(url: string) {
57
58
  }
58
59
  }
59
60
 
61
+ function comparableUrl(value: string) {
62
+ try {
63
+ const parsed = new URL(value);
64
+ return `${parsed.protocol}//${parsed.hostname.replace(/^www\./, "")}${parsed.pathname}`;
65
+ } catch {
66
+ return value.split("?")[0] ?? value;
67
+ }
68
+ }
69
+
70
+ export function isTweetArticleUrlEntity(
71
+ entry: TweetUrlEntity,
72
+ article: TweetArticle,
73
+ ) {
74
+ if (comparableUrl(entry.expandedUrl) === comparableUrl(article.url)) {
75
+ return true;
76
+ }
77
+ try {
78
+ const parsed = new URL(entry.expandedUrl);
79
+ const host = parsed.hostname.replace(/^www\./, "");
80
+ return (
81
+ (host === "x.com" || host === "twitter.com") &&
82
+ parsed.pathname.startsWith("/i/article/")
83
+ );
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
60
89
  function asRecord(value: unknown) {
61
90
  return value && typeof value === "object"
62
91
  ? (value as Record<string, unknown>)
@@ -68,6 +97,9 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
68
97
  const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
69
98
  const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
70
99
  const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
100
+ const rawArticle = asRecord(entities.article);
101
+ const articleTitle = String(rawArticle.title ?? "").trim();
102
+ const articleUrl = String(rawArticle.url ?? "").trim();
71
103
 
72
104
  return {
73
105
  ...(rawMentions.length
@@ -102,6 +134,23 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
102
134
  ),
103
135
  start: Number(value.start ?? 0),
104
136
  end: Number(value.end ?? 0),
137
+ ...(typeof value.title === "string"
138
+ ? { title: value.title }
139
+ : {}),
140
+ ...(typeof value.description === "string" ||
141
+ value.description === null
142
+ ? { description: value.description }
143
+ : {}),
144
+ ...(typeof value.imageUrl === "string"
145
+ ? { imageUrl: value.imageUrl }
146
+ : typeof value.image_url === "string"
147
+ ? { imageUrl: value.image_url }
148
+ : {}),
149
+ ...(typeof value.siteName === "string"
150
+ ? { siteName: value.siteName }
151
+ : typeof value.site_name === "string"
152
+ ? { siteName: value.site_name }
153
+ : {}),
105
154
  };
106
155
  }),
107
156
  }
@@ -118,6 +167,35 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
118
167
  }),
119
168
  }
120
169
  : {}),
170
+ ...(articleTitle && articleUrl
171
+ ? {
172
+ article: {
173
+ title: articleTitle,
174
+ url: articleUrl,
175
+ ...(typeof (rawArticle.previewText ?? rawArticle.preview_text) ===
176
+ "string" &&
177
+ String(rawArticle.previewText ?? rawArticle.preview_text).trim()
178
+ ? {
179
+ previewText: String(
180
+ rawArticle.previewText ?? rawArticle.preview_text,
181
+ ).trim(),
182
+ }
183
+ : {}),
184
+ ...(typeof (
185
+ rawArticle.coverImageUrl ?? rawArticle.cover_image_url
186
+ ) === "string" &&
187
+ String(
188
+ rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
189
+ ).trim()
190
+ ? {
191
+ coverImageUrl: String(
192
+ rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
193
+ ).trim(),
194
+ }
195
+ : {}),
196
+ },
197
+ }
198
+ : {}),
121
199
  };
122
200
  }
123
201
 
@@ -5,6 +5,7 @@ import { getNativeDb } from "./db";
5
5
  import { runEffectPromise } from "./effect-runtime";
6
6
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
7
  import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ import { tweetEntitiesFromXurl } from "./tweet-render";
8
9
  import type { XurlMentionsResponse, XurlTweetsResponse } from "./types";
9
10
  import { upsertTweetAccountEdge } from "./tweet-account-edges";
10
11
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
@@ -190,7 +191,7 @@ function mergeTweetSearchIntoLocalStore(
190
191
  replyToId,
191
192
  Number(tweet.public_metrics?.like_count ?? 0),
192
193
  countTweetMedia(tweet),
193
- JSON.stringify(tweet.entities ?? {}),
194
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
194
195
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
195
196
  quotedTweetId,
196
197
  );
package/src/lib/types.ts CHANGED
@@ -101,10 +101,18 @@ export interface TweetHashtagEntity {
101
101
  end: number;
102
102
  }
103
103
 
104
+ export interface TweetArticle {
105
+ title: string;
106
+ previewText?: string;
107
+ url: string;
108
+ coverImageUrl?: string;
109
+ }
110
+
104
111
  export interface TweetEntities {
105
112
  mentions?: TweetMentionEntity[];
106
113
  urls?: TweetUrlEntity[];
107
114
  hashtags?: TweetHashtagEntity[];
115
+ article?: TweetArticle;
108
116
  }
109
117
 
110
118
  export interface TweetMediaItem {
package/src/lib/ui.ts CHANGED
@@ -311,7 +311,7 @@ export const profilePreviewTriggerClass =
311
311
  "profile-preview-trigger inline-flex text-inherit";
312
312
 
313
313
  export const profilePreviewCardClass =
314
- "pointer-events-none absolute left-0 z-30 grid w-[280px] gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 opacity-0 shadow-[0_8px_28px_var(--shadow-strong)] transition-all duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
314
+ "fixed z-40 w-[280px] overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 shadow-[0_8px_28px_var(--shadow-strong)]";
315
315
 
316
316
  export const profilePreviewHeaderClass = "flex items-center gap-3";
317
317
 
package/src/router.tsx CHANGED
@@ -7,7 +7,7 @@ export function getRouter() {
7
7
 
8
8
  scrollRestoration: true,
9
9
  defaultPreload: "intent",
10
- defaultPreloadStaleTime: 0,
10
+ defaultPreloadStaleTime: 60_000,
11
11
  });
12
12
 
13
13
  return router;
@@ -1,11 +1,9 @@
1
- import { TanStackDevtools } from "@tanstack/react-devtools";
2
1
  import {
3
2
  createRootRoute,
4
3
  HeadContent,
5
4
  Scripts,
6
5
  useRouterState,
7
6
  } from "@tanstack/react-router";
8
- import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
9
7
  import type { ReactNode } from "react";
10
8
  import { AppNav } from "#/components/AppNav";
11
9
  import { ThemeProvider, themeScript } from "#/lib/theme";
@@ -74,17 +72,6 @@ function RootDocument({ children }: { children: ReactNode }) {
74
72
  </main>
75
73
  </div>
76
74
  </ThemeProvider>
77
- <TanStackDevtools
78
- config={{
79
- position: "bottom-right",
80
- }}
81
- plugins={[
82
- {
83
- name: "Tanstack Router",
84
- render: <TanStackRouterDevtoolsPanel />,
85
- },
86
- ]}
87
- />
88
75
  <Scripts />
89
76
  </body>
90
77
  </html>
@@ -1,13 +1,13 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Effect } from "effect";
3
3
  import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
- import { runEffectBackground } from "#/lib/effect-runtime";
5
4
  import {
6
5
  jsonResponse,
7
6
  parseBoundedInteger,
8
7
  runRouteEffect,
9
8
  sensitiveRequestErrorResponse,
10
9
  } from "#/lib/http-effect";
10
+ import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
11
11
  import {
12
12
  normalizeDigestLanguage,
13
13
  streamPeriodDigestEffect,
@@ -15,8 +15,6 @@ import {
15
15
  type PeriodDigestStreamEvent,
16
16
  } from "#/lib/period-digest";
17
17
 
18
- const encoder = new TextEncoder();
19
-
20
18
  function parseBoolean(value: string | null) {
21
19
  return value === "true" || value === "1" || value === "yes";
22
20
  }
@@ -52,16 +50,12 @@ function parseOptions(url: URL): PeriodDigestOptions {
52
50
  };
53
51
  }
54
52
 
55
- function encodeEvent(event: PeriodDigestStreamEvent) {
56
- return encoder.encode(`${JSON.stringify(event)}\n`);
57
- }
58
-
59
53
  export const Route = createFileRoute("/api/period-digest")({
60
54
  server: {
61
55
  handlers: {
62
56
  GET: ({ request }) =>
63
57
  runRouteEffect(
64
- Effect.gen(function* () {
58
+ Effect.sync(() => {
65
59
  const denied = sensitiveRequestErrorResponse(request);
66
60
  if (denied) return denied;
67
61
 
@@ -78,70 +72,28 @@ export const Route = createFileRoute("/api/period-digest")({
78
72
  { status: 400 },
79
73
  );
80
74
  }
81
- yield* maybeAutoUpdateBackupEffect();
82
- let abortDigest: (() => void) | undefined;
83
-
84
- return new Response(
85
- new ReadableStream({
86
- cancel() {
87
- abortDigest?.();
75
+ return createEffectNdjsonResponse<PeriodDigestStreamEvent>({
76
+ request,
77
+ initialEvents: [
78
+ {
79
+ type: "status",
80
+ label: "Preparing local archive",
81
+ detail: "Checking for backup updates.",
88
82
  },
89
- start(controller) {
90
- const abortController = new AbortController();
91
- let closed = false;
92
- const close = () => {
93
- closed = true;
94
- abortController.abort();
95
- };
96
- const closeController = () => {
97
- request.signal.removeEventListener("abort", onAbort);
98
- if (!closed) {
99
- closed = true;
100
- controller.close();
101
- }
102
- };
103
- const onAbort = () => close();
104
- request.signal.addEventListener("abort", onAbort, {
105
- once: true,
106
- });
107
- abortDigest = close;
108
- const enqueue = (event: PeriodDigestStreamEvent) => {
109
- if (closed) return;
110
- try {
111
- controller.enqueue(encodeEvent(event));
112
- } catch {
113
- close();
114
- }
115
- };
116
-
117
- runEffectBackground(
118
- streamPeriodDigestEffect(
119
- { ...options, signal: abortController.signal },
120
- { onEvent: enqueue },
121
- ),
122
- {
123
- onSuccess: closeController,
124
- onFailure: (error) => {
125
- enqueue({
126
- type: "error",
127
- error:
128
- error instanceof Error
129
- ? error.message
130
- : "Digest failed",
131
- });
132
- closeController();
133
- },
134
- },
83
+ ],
84
+ run: ({ signal, emit }) =>
85
+ Effect.gen(function* () {
86
+ yield* maybeAutoUpdateBackupEffect();
87
+ return yield* streamPeriodDigestEffect(
88
+ { ...options, signal },
89
+ { onEvent: emit },
135
90
  );
136
- },
91
+ }),
92
+ errorEvent: (error) => ({
93
+ type: "error",
94
+ error: error instanceof Error ? error.message : "Digest failed",
137
95
  }),
138
- {
139
- headers: {
140
- "cache-control": "no-store",
141
- "content-type": "application/x-ndjson; charset=utf-8",
142
- },
143
- },
144
- );
96
+ });
145
97
  }),
146
98
  ),
147
99
  },