@jant/core 0.3.42 → 0.3.43

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 (79) hide show
  1. package/bin/commands/import-site.js +1 -1
  2. package/bin/commands/search-reindex.js +175 -0
  3. package/bin/lib/hugo-markdown.js +102 -0
  4. package/bin/lib/site-pull-media.js +1 -4
  5. package/dist/app-Ctl0T0zO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-GbfwoeDJ.js} +630 -123
  7. package/dist/client/.vite/manifest.json +1 -1
  8. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  9. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  10. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  11. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  12. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  13. package/dist/index.js +5 -5
  14. package/dist/node.js +5 -5
  15. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  16. package/package.json +1 -1
  17. package/src/__tests__/helpers/app.ts +15 -4
  18. package/src/app.tsx +8 -0
  19. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  20. package/src/client/tiptap/extensions.ts +3 -0
  21. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  22. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  23. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  24. package/src/db/migrations/meta/_journal.json +8 -1
  25. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  26. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  27. package/src/db/migrations/pg/meta/_journal.json +8 -1
  28. package/src/db/pg/schema.ts +18 -0
  29. package/src/db/schema.ts +23 -0
  30. package/src/index.ts +1 -2
  31. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  32. package/src/lib/__tests__/navigation.test.ts +4 -20
  33. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  34. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  35. package/src/lib/__tests__/summary.test.ts +140 -0
  36. package/src/lib/__tests__/view.test.ts +66 -0
  37. package/src/lib/feed.ts +70 -34
  38. package/src/lib/hosted-signin.ts +9 -3
  39. package/src/lib/navigation.ts +11 -12
  40. package/src/lib/post-meta.ts +20 -2
  41. package/src/lib/rate-limit-d1.ts +99 -0
  42. package/src/lib/rate-limit-memory.ts +105 -0
  43. package/src/lib/rate-limit.ts +63 -0
  44. package/src/lib/render.tsx +9 -0
  45. package/src/lib/resolve-config.ts +9 -0
  46. package/src/lib/summary.ts +42 -7
  47. package/src/lib/url.ts +34 -0
  48. package/src/lib/view.ts +42 -8
  49. package/src/middleware/__tests__/auth.test.ts +44 -4
  50. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  51. package/src/middleware/__tests__/session.test.ts +85 -0
  52. package/src/middleware/auth.ts +62 -25
  53. package/src/middleware/rate-limit.ts +54 -0
  54. package/src/middleware/session.ts +36 -0
  55. package/src/routes/__tests__/compose.test.ts +1 -1
  56. package/src/routes/api/__tests__/search.test.ts +48 -0
  57. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  58. package/src/routes/api/internal/search-reindex.ts +40 -0
  59. package/src/routes/api/search.ts +13 -0
  60. package/src/routes/auth/dev.ts +1 -1
  61. package/src/routes/auth/signin.tsx +23 -5
  62. package/src/routes/dash/settings.tsx +3 -5
  63. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  64. package/src/routes/feed/sitemap.ts +208 -33
  65. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  66. package/src/routes/pages/home.tsx +24 -15
  67. package/src/routes/pages/page.tsx +34 -0
  68. package/src/routes/pages/partials.tsx +4 -15
  69. package/src/runtime/cloudflare.ts +4 -0
  70. package/src/runtime/node.ts +16 -0
  71. package/src/services/__tests__/post.test.ts +205 -0
  72. package/src/services/__tests__/search.test.ts +44 -0
  73. package/src/services/export.ts +9 -2
  74. package/src/services/post.ts +200 -2
  75. package/src/types/app-context.ts +20 -0
  76. package/src/types/config.ts +8 -0
  77. package/src/types/props.ts +0 -7
  78. package/src/ui/layouts/BaseLayout.tsx +9 -0
  79. package/dist/app-DzCB4yOp.js +0 -5
@@ -1,60 +1,235 @@
1
1
  /**
2
2
  * Sitemap Routes
3
+ *
4
+ * Sitemap is sharded to keep each shard small, cache-friendly, and stable:
5
+ *
6
+ * /sitemap.xml → sitemap index listing all shards
7
+ * /sitemap-posts-N.xml → one shard of published non-reply posts
8
+ * /sitemap-collections.xml → public collection pages
9
+ * /sitemap-pages.xml → homepage + static aggregate pages
10
+ *
11
+ * Post shards are keyset-paginated by post `id` (TypeIDs embed a
12
+ * creation-ordered UUIDv7 timestamp), so once a shard fills up its membership
13
+ * never changes: new posts always land in the last shard, never rewriting an
14
+ * older one. This lets old shards be cached at the edge for a long time.
3
15
  */
4
16
 
5
17
  import { Hono } from "hono";
6
18
  import type { Bindings } from "../../types.js";
7
19
  import type { AppVariables } from "../../types/app-context.js";
8
- import { defaultSitemapRenderer } from "../../lib/feed.js";
9
- import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
20
+ import {
21
+ renderSitemapIndex,
22
+ renderSitemapUrlSet,
23
+ SITEMAP_SHARD_SIZE,
24
+ type SitemapIndexEntry,
25
+ type SitemapUrlEntry,
26
+ } from "../../lib/feed.js";
10
27
  import { toAbsoluteSiteUrl } from "../../lib/url.js";
11
28
 
12
29
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
30
 
14
31
  export const sitemapRoutes = new Hono<Env>();
15
32
 
16
- // XML Sitemap
33
+ const CACHE_SHORT = "public, max-age=180";
34
+ const CACHE_FULL_SHARD = "public, max-age=86400, s-maxage=86400";
35
+
36
+ function xmlResponse(xml: string, cacheControl: string): Response {
37
+ return new Response(xml, {
38
+ headers: {
39
+ "Content-Type": "application/xml; charset=utf-8",
40
+ "Cache-Control": cacheControl,
41
+ },
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Build a public URL entry (absolute URL) from an internal path.
47
+ */
48
+ function absoluteUrl(
49
+ internalPath: string,
50
+ siteUrl: string,
51
+ sitePathPrefix: string,
52
+ ): string {
53
+ return toAbsoluteSiteUrl(internalPath, siteUrl, sitePathPrefix);
54
+ }
55
+
56
+ /** Convert a unix-seconds timestamp into a `YYYY-MM-DD` string. */
57
+ function toIsoDate(unixSeconds: number): string {
58
+ return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
59
+ }
60
+
61
+ // =============================================================================
62
+ // Sitemap Index
63
+ // =============================================================================
64
+
17
65
  sitemapRoutes.get("/sitemap.xml", async (c) => {
18
66
  const { appConfig } = c.var;
19
- const siteUrl = appConfig.siteUrl;
67
+ const { siteUrl, sitePathPrefix } = appConfig;
20
68
 
21
- const posts = await c.var.services.posts.list({
22
- status: "published",
23
- excludeReplies: true,
24
- excludePrivate: true,
25
- limit: 1000,
26
- });
69
+ const postCount = await c.var.services.posts.countForSitemap();
70
+ const postShardCount = Math.max(1, Math.ceil(postCount / SITEMAP_SHARD_SIZE));
27
71
 
28
- // Transform to View Models
29
- const mediaCtx = createMediaContext(appConfig);
30
- const aliasesMap = await c.var.services.paths.getPostAliases(
31
- posts.map((p) => p.id),
32
- );
33
- const aliasMap = new Map<string, string>();
34
- for (const [id, aliases] of aliasesMap) {
35
- if (aliases[0]) aliasMap.set(id, aliases[0]);
72
+ const entries: SitemapIndexEntry[] = [
73
+ { loc: absoluteUrl("/sitemap-pages.xml", siteUrl, sitePathPrefix) },
74
+ ];
75
+
76
+ // Only include post shards if there's at least one post. When postCount is
77
+ // 0 we still list `sitemap-posts-1.xml` so the site always has a posts
78
+ // shard the renderer emits an empty <urlset>, which is valid.
79
+ for (let page = 1; page <= postShardCount; page++) {
80
+ entries.push({
81
+ loc: absoluteUrl(`/sitemap-posts-${page}.xml`, siteUrl, sitePathPrefix),
82
+ });
36
83
  }
37
- const postViews = toPostViewsFromPosts(posts, mediaCtx, undefined, aliasMap);
38
-
39
- const xml = defaultSitemapRenderer({
40
- siteUrl,
41
- sitemapUrl: toAbsoluteSiteUrl(
42
- "/sitemap.xml",
43
- siteUrl,
44
- appConfig.sitePathPrefix,
45
- ),
46
- posts: postViews,
84
+
85
+ const collections = await c.var.services.collections.list();
86
+ if (collections.length > 0) {
87
+ entries.push({
88
+ loc: absoluteUrl("/sitemap-collections.xml", siteUrl, sitePathPrefix),
89
+ });
90
+ }
91
+
92
+ return xmlResponse(renderSitemapIndex(entries), CACHE_SHORT);
93
+ });
94
+
95
+ // =============================================================================
96
+ // Post Shards
97
+ // =============================================================================
98
+
99
+ // Hono's path parser does not allow a param alongside a literal prefix in
100
+ // the same segment (e.g. `/sitemap-posts-:page.xml` does not match). The
101
+ // param must own the whole segment, so we match the full filename with a
102
+ // regex and parse the page number out inside the handler.
103
+ sitemapRoutes.get("/:file{sitemap-posts-[0-9]+\\.xml}", async (c) => {
104
+ const { appConfig } = c.var;
105
+ const { siteUrl, sitePathPrefix } = appConfig;
106
+ const file = c.req.param("file");
107
+ const match = /^sitemap-posts-([0-9]+)\.xml$/.exec(file);
108
+ if (!match) return c.notFound();
109
+ const page = Number(match[1]);
110
+ if (!Number.isFinite(page) || page < 1) return c.notFound();
111
+
112
+ // Keyset cursor: for page N (>1) we want the id just before the shard's
113
+ // first row, so `listForSitemap({ afterId })` returns the shard. For page
114
+ // 1 there is no cursor.
115
+ let afterId: string | undefined;
116
+ if (page > 1) {
117
+ const cursorOffset = (page - 1) * SITEMAP_SHARD_SIZE - 1;
118
+ const cursor = await c.var.services.posts.getSitemapIdAt(cursorOffset);
119
+ if (cursor === null) return c.notFound();
120
+ afterId = cursor;
121
+ }
122
+
123
+ const shardEntries = await c.var.services.posts.listForSitemap({
124
+ afterId,
125
+ limit: SITEMAP_SHARD_SIZE,
47
126
  });
48
127
 
49
- return new Response(xml, {
50
- headers: {
51
- "Content-Type": "application/xml; charset=utf-8",
52
- "Cache-Control": "public, max-age=180",
128
+ const urls: SitemapUrlEntry[] = shardEntries.map((entry) => {
129
+ // `entry.alias` already includes a leading "/" (see
130
+ // `paths.getPostAliases`); slugs are stored raw. Prepending "/" to an
131
+ // alias would create "//path" which `new URL()` interprets as
132
+ // protocol-relative and hijacks the hostname.
133
+ const path = entry.alias ?? `/${entry.slug}`;
134
+ return {
135
+ loc: absoluteUrl(path, siteUrl, sitePathPrefix),
136
+ lastmod: toIsoDate(entry.updatedAt),
137
+ priority: entry.featuredAt ? "0.8" : "0.6",
138
+ };
139
+ });
140
+
141
+ // The last (not-yet-filled) shard needs short caching because new posts
142
+ // will append to it. Full shards are immutable in membership and can be
143
+ // cached aggressively — only a post edit inside them moves `<lastmod>`,
144
+ // which is acceptable sitemap staleness.
145
+ const isFullShard = shardEntries.length === SITEMAP_SHARD_SIZE;
146
+ const cacheControl = isFullShard ? CACHE_FULL_SHARD : CACHE_SHORT;
147
+
148
+ return xmlResponse(renderSitemapUrlSet(urls), cacheControl);
149
+ });
150
+
151
+ // =============================================================================
152
+ // Collections Shard
153
+ // =============================================================================
154
+
155
+ sitemapRoutes.get("/sitemap-collections.xml", async (c) => {
156
+ const { appConfig } = c.var;
157
+ const { siteUrl, sitePathPrefix } = appConfig;
158
+
159
+ const collections = await c.var.services.collections.list();
160
+
161
+ // Resolve each collection's canonical URL (alias if one exists, else slug).
162
+ // The `/collections` directory itself lives in `/sitemap-pages.xml`, since
163
+ // it's a static aggregate page rather than per-collection content.
164
+ const urls: SitemapUrlEntry[] = await Promise.all(
165
+ collections.map(async (collection) => {
166
+ const alias = await c.var.services.customUrls.getByTarget(
167
+ "collection",
168
+ collection.id,
169
+ );
170
+ const path = alias ? `/${alias.path}` : `/${collection.slug}`;
171
+ return {
172
+ loc: absoluteUrl(path, siteUrl, sitePathPrefix),
173
+ lastmod: toIsoDate(collection.updatedAt),
174
+ priority: "0.7",
175
+ };
176
+ }),
177
+ );
178
+
179
+ return xmlResponse(renderSitemapUrlSet(urls), CACHE_SHORT);
180
+ });
181
+
182
+ // =============================================================================
183
+ // Static Pages Shard (homepage)
184
+ // =============================================================================
185
+
186
+ sitemapRoutes.get("/sitemap-pages.xml", async (c) => {
187
+ const { appConfig } = c.var;
188
+ const { siteUrl, sitePathPrefix, homeDefaultView } = appConfig;
189
+
190
+ const urls: SitemapUrlEntry[] = [
191
+ {
192
+ loc: absoluteUrl("/", siteUrl, sitePathPrefix),
193
+ priority: "1.0",
194
+ changefreq: "daily",
53
195
  },
196
+ {
197
+ loc: absoluteUrl("/archive", siteUrl, sitePathPrefix),
198
+ priority: "0.5",
199
+ changefreq: "weekly",
200
+ },
201
+ ];
202
+
203
+ // Whichever of /latest and /featured is NOT the homepage default is a
204
+ // standalone URL worth indexing; the other 302-redirects to `/`.
205
+ const secondaryAggregate =
206
+ homeDefaultView === "featured" ? "/latest" : "/featured";
207
+ urls.push({
208
+ loc: absoluteUrl(secondaryAggregate, siteUrl, sitePathPrefix),
209
+ priority: "0.6",
210
+ changefreq: "daily",
54
211
  });
212
+
213
+ // Include the collections directory landing page when at least one
214
+ // collection exists. When there are no collections, `/collections` still
215
+ // renders (as an empty directory), but indexing an empty aggregate page
216
+ // adds no value.
217
+ const collections = await c.var.services.collections.list();
218
+ if (collections.length > 0) {
219
+ urls.push({
220
+ loc: absoluteUrl("/collections", siteUrl, sitePathPrefix),
221
+ priority: "0.5",
222
+ changefreq: "weekly",
223
+ });
224
+ }
225
+
226
+ return xmlResponse(renderSitemapUrlSet(urls), CACHE_SHORT);
55
227
  });
56
228
 
229
+ // =============================================================================
57
230
  // robots.txt
231
+ // =============================================================================
232
+
58
233
  sitemapRoutes.get("/robots.txt", async (c) => {
59
234
  const { appConfig } = c.var;
60
235
  const siteUrl = appConfig.siteUrl;
@@ -74,7 +249,7 @@ sitemapRoutes.get("/robots.txt", async (c) => {
74
249
  return new Response(robots, {
75
250
  headers: {
76
251
  "Content-Type": "text/plain; charset=utf-8",
77
- "Cache-Control": "public, max-age=180",
252
+ "Cache-Control": CACHE_SHORT,
78
253
  },
79
254
  });
80
255
  });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Tests for the `<link rel="canonical">` tag on post pages.
3
+ *
4
+ * Reply URLs render the full thread, so each reply URL would otherwise look
5
+ * like duplicate content to crawlers. The canonical tag points every page in
6
+ * the thread back to the thread root.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
11
+ import { pageRoutes } from "../page.js";
12
+
13
+ function createPageTestApp() {
14
+ const testApp = createTestApp();
15
+ const { app } = testApp;
16
+
17
+ app.use("*", async (c, next) => {
18
+ c.set("publicPath", c.req.path);
19
+ c.set("publicRequestUrl", c.req.url);
20
+ await next();
21
+ });
22
+
23
+ app.route("/", pageRoutes);
24
+
25
+ return testApp;
26
+ }
27
+
28
+ function extractCanonicalHref(html: string): string | null {
29
+ const match = html.match(/<link\s+rel="canonical"\s+href="([^"]+)"\s*\/?>/i);
30
+ return match?.[1] ?? null;
31
+ }
32
+
33
+ describe("Post page canonical link", () => {
34
+ it("root post canonical points at its own permalink", async () => {
35
+ const { app, services } = createPageTestApp();
36
+
37
+ const root = await services.posts.create({
38
+ format: "note",
39
+ title: "Root post",
40
+ bodyMarkdown: "Root body",
41
+ status: "published",
42
+ });
43
+
44
+ const res = await app.request(`/${root.slug}`);
45
+ expect(res.status).toBe(200);
46
+
47
+ const html = await res.text();
48
+ const canonical = extractCanonicalHref(html);
49
+ expect(canonical).not.toBeNull();
50
+ expect(canonical).toMatch(new RegExp(`/${root.slug}$`));
51
+ });
52
+
53
+ it("reply canonical points back to the thread root", async () => {
54
+ const { app, services } = createPageTestApp();
55
+
56
+ const root = await services.posts.create({
57
+ format: "note",
58
+ title: "Thread root",
59
+ bodyMarkdown: "Root body",
60
+ status: "published",
61
+ });
62
+ const reply = await services.posts.create({
63
+ format: "note",
64
+ bodyMarkdown: "Reply body",
65
+ replyToId: root.id,
66
+ status: "published",
67
+ });
68
+
69
+ // Visiting the reply URL should canonicalize to the root URL.
70
+ const replyRes = await app.request(`/${reply.slug}`);
71
+ expect(replyRes.status).toBe(200);
72
+
73
+ const replyHtml = await replyRes.text();
74
+ const replyCanonical = extractCanonicalHref(replyHtml);
75
+ expect(replyCanonical).not.toBeNull();
76
+ expect(replyCanonical).toMatch(new RegExp(`/${root.slug}$`));
77
+ expect(replyCanonical).not.toMatch(new RegExp(`/${reply.slug}$`));
78
+
79
+ // And the root URL should canonicalize to itself.
80
+ const rootRes = await app.request(`/${root.slug}`);
81
+ const rootCanonical = extractCanonicalHref(await rootRes.text());
82
+ expect(rootCanonical).toMatch(new RegExp(`/${root.slug}$`));
83
+ });
84
+
85
+ it("canonical is absolute when siteUrl is configured", async () => {
86
+ const { app, services } = createPageTestApp();
87
+
88
+ const post = await services.posts.create({
89
+ format: "note",
90
+ title: "Absolute test",
91
+ bodyMarkdown: "Body",
92
+ status: "published",
93
+ });
94
+
95
+ const res = await app.request(`/${post.slug}`);
96
+ const canonical = extractCanonicalHref(await res.text());
97
+ // SITE_ORIGIN in the test harness is http://localhost:<port>
98
+ expect(canonical).toMatch(/^https?:\/\//);
99
+ expect(canonical).toContain(`/${post.slug}`);
100
+ });
101
+ });
@@ -12,7 +12,10 @@ import { Hono } from "hono";
12
12
  import { msg } from "@lingui/core/macro";
13
13
  import type { Bindings } from "../../types.js";
14
14
  import type { AppVariables } from "../../types/app-context.js";
15
- import { getNavigationData } from "../../lib/navigation.js";
15
+ import {
16
+ getHomeDefaultViewFromNavItems,
17
+ getNavigationData,
18
+ } from "../../lib/navigation.js";
16
19
  import { getI18n } from "../../i18n/index.js";
17
20
  import { formatPageLabel, parsePageNumber } from "../../lib/pagination.js";
18
21
  import { buildPageTitle } from "../../lib/page-title.js";
@@ -30,25 +33,36 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
30
33
  export const homeRoutes = new Hono<Env>();
31
34
 
32
35
  homeRoutes.get("/", async (c) => {
33
- const navData = await getNavigationData(c);
34
36
  const i18n = getI18n(c);
35
37
  const page = parsePageNumber(c.req.query("page"));
36
38
  const paginatedPageTitle = formatPageLabel(page);
39
+ const isAuthenticated = c.var.isAuthenticated;
40
+
41
+ // Fetch nav items once — we need `homeDefaultView` to decide which timeline
42
+ // to assemble, but `getNavigationData` also consumes them. Passing them
43
+ // through avoids a duplicate DB query and unlocks the Promise.all below.
44
+ const navItems = await c.var.services.navItems.list();
45
+ const homeDefaultView = getHomeDefaultViewFromNavItems(navItems);
46
+
47
+ const timelinePromise =
48
+ homeDefaultView === "featured"
49
+ ? assembleFeaturedTimeline(c, { page, isAuthenticated })
50
+ : assembleTimeline(c, { page, isAuthenticated });
51
+
52
+ const [navData, timeline] = await Promise.all([
53
+ getNavigationData(c, { preloadedItems: navItems }),
54
+ timelinePromise,
55
+ ]);
37
56
 
38
- if (navData.homeDefaultView === "featured") {
57
+ const { items, currentPage, totalPages } = timeline;
58
+
59
+ if (homeDefaultView === "featured") {
39
60
  const featuredTitle = i18n._(
40
61
  msg({
41
62
  message: "Featured",
42
63
  comment: "@context: Browser page title for the featured feed",
43
64
  }),
44
65
  );
45
- const { items, currentPage, totalPages } = await assembleFeaturedTimeline(
46
- c,
47
- {
48
- page,
49
- isAuthenticated: navData.isAuthenticated,
50
- },
51
- );
52
66
 
53
67
  return renderPublicPage(c, {
54
68
  title:
@@ -76,11 +90,6 @@ homeRoutes.get("/", async (c) => {
76
90
  }),
77
91
  );
78
92
 
79
- const { items, currentPage, totalPages } = await assembleTimeline(c, {
80
- page,
81
- isAuthenticated: navData.isAuthenticated,
82
- });
83
-
84
93
  return renderPublicPage(c, {
85
94
  title:
86
95
  page > 1
@@ -33,6 +33,28 @@ interface TextPreviewAutoOpen {
33
33
  mediaId: string;
34
34
  }
35
35
 
36
+ /**
37
+ * Build the canonical absolute URL for a post page.
38
+ *
39
+ * Reply URLs render the full thread, so search engines see overlapping
40
+ * content at every reply URL. Point the canonical to the thread root so
41
+ * crawlers consolidate ranking on one URL.
42
+ *
43
+ * The root post is always at index 0 of `threadPostViews` (getThread orders
44
+ * by createdAt ASC, and the DB check constraint guarantees root has the
45
+ * smallest createdAt in its thread). When `threadPostViews` is undefined the
46
+ * post is not part of a multi-post thread, so the post itself is the root.
47
+ */
48
+ function buildPostCanonicalHref(
49
+ postView: { permalink: string },
50
+ threadPostViews: Array<{ permalink: string }> | undefined,
51
+ siteUrl: string,
52
+ ): string {
53
+ const rootPermalink = threadPostViews?.[0]?.permalink ?? postView.permalink;
54
+ if (!siteUrl) return rootPermalink;
55
+ return new URL(rootPermalink, siteUrl).toString();
56
+ }
57
+
36
58
  async function renderPostWithTextPreview(
37
59
  c: Context<Env>,
38
60
  post: Post,
@@ -48,6 +70,11 @@ async function renderPostWithTextPreview(
48
70
 
49
71
  const navData = await navDataPromise;
50
72
  const meta = buildPostMeta(post, navData.siteName);
73
+ const canonicalHref = buildPostCanonicalHref(
74
+ display.postView,
75
+ display.threadPostViews,
76
+ c.var.appConfig.siteUrl,
77
+ );
51
78
 
52
79
  // Use the attachment summary as the page title (for OG/link previews),
53
80
  // and pass the post title in the payload so the client can restore it
@@ -68,6 +95,7 @@ async function renderPostWithTextPreview(
68
95
  return renderPublicPage(c, {
69
96
  title: pageTitle,
70
97
  description: meta.description,
98
+ canonicalHref,
71
99
  navData,
72
100
  content: (
73
101
  <>
@@ -127,10 +155,16 @@ async function renderPost(c: Context<Env>, post: Post) {
127
155
 
128
156
  const navData = await navDataPromise;
129
157
  const meta = buildPostMeta(post, navData.siteName);
158
+ const canonicalHref = buildPostCanonicalHref(
159
+ display.postView,
160
+ display.threadPostViews,
161
+ c.var.appConfig.siteUrl,
162
+ );
130
163
 
131
164
  return renderPublicPage(c, {
132
165
  title: meta.title,
133
166
  description: meta.description,
167
+ canonicalHref,
134
168
  navData,
135
169
  content: (
136
170
  <PostPage post={display.postView} threadPosts={display.threadPostViews} />
@@ -1,4 +1,4 @@
1
- import { Hono, type Context } from "hono";
1
+ import { Hono } from "hono";
2
2
  import { I18nProvider } from "../../i18n/index.js";
3
3
  import { parseIdParam } from "../../lib/errors.js";
4
4
  import { ID_PREFIX } from "../../lib/ids.js";
@@ -18,17 +18,6 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
18
18
 
19
19
  export const partialPageRoutes = new Hono<Env>();
20
20
 
21
- async function getIsAuthenticated(c: Context<Env>): Promise<boolean> {
22
- try {
23
- const session = await c.var.auth.api.getSession({
24
- headers: c.req.raw.headers,
25
- });
26
- return !!session?.user;
27
- } catch {
28
- return false;
29
- }
30
- }
31
-
32
21
  partialPageRoutes.get("/_/version", (c) => {
33
22
  return c.json({ version: CORE_VERSION });
34
23
  });
@@ -39,7 +28,7 @@ partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
39
28
  ID_PREFIX.post,
40
29
  );
41
30
  const item = await assembleTimelineItem(c, threadRootId, {
42
- isAuthenticated: await getIsAuthenticated(c),
31
+ isAuthenticated: c.var.isAuthenticated,
43
32
  });
44
33
 
45
34
  if (!item) {
@@ -56,7 +45,7 @@ partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
56
45
  partialPageRoutes.get("/_/post-card/:postId", async (c) => {
57
46
  const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
58
47
  const postView = await assemblePostCardView(c, postId, {
59
- isAuthenticated: await getIsAuthenticated(c),
48
+ isAuthenticated: c.var.isAuthenticated,
60
49
  });
61
50
 
62
51
  if (!postView) {
@@ -73,7 +62,7 @@ partialPageRoutes.get("/_/post-card/:postId", async (c) => {
73
62
  partialPageRoutes.get("/_/post-view/:postId", async (c) => {
74
63
  const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
75
64
  const display = await assemblePostPageDisplay(c, postId, {
76
- isAuthenticated: await getIsAuthenticated(c),
65
+ isAuthenticated: c.var.isAuthenticated,
77
66
  });
78
67
 
79
68
  if (!display) {
@@ -10,6 +10,8 @@ import {
10
10
  shouldUseSecureCookies,
11
11
  } from "../lib/env.js";
12
12
  import { createHostedControlPlaneClient } from "../lib/hosted-control-plane.js";
13
+ import { createD1RateLimiter } from "../lib/rate-limit-d1.js";
14
+ import type { RateLimiter } from "../lib/rate-limit.js";
13
15
  import { createStorageDriver, type StorageDriver } from "../lib/storage.js";
14
16
  import {
15
17
  createHostedHandoffService,
@@ -30,6 +32,7 @@ export interface CloudflareRequestRuntime {
30
32
  currentSiteDomain: SiteDomain | null;
31
33
  db: Database;
32
34
  hostedHandoff: HostedHandoffService;
35
+ rateLimiter: RateLimiter;
33
36
  services: Services;
34
37
  storage: StorageDriver | null;
35
38
  }
@@ -88,6 +91,7 @@ export async function createCloudflareRequestRuntime(
88
91
  schema: sqliteSchemaBundle,
89
92
  secret: hostedControlPlaneSsoSecret,
90
93
  }),
94
+ rateLimiter: createD1RateLimiter(db, sqliteSchemaBundle),
91
95
  services: createServices(db, session, siteLookup.site.id, {
92
96
  databaseDialect: "sqlite",
93
97
  bootstrapSite: getSingleSiteBootstrapOptions(env),
@@ -12,6 +12,8 @@ import {
12
12
  shouldUseSecureCookies,
13
13
  } from "../lib/env.js";
14
14
  import { createHostedControlPlaneClient } from "../lib/hosted-control-plane.js";
15
+ import { createMemoryRateLimiter } from "../lib/rate-limit-memory.js";
16
+ import type { RateLimiter } from "../lib/rate-limit.js";
15
17
  import { createStorageDriver, type StorageDriver } from "../lib/storage.js";
16
18
  import {
17
19
  createHostedHandoffService,
@@ -33,10 +35,23 @@ export interface NodeRequestRuntime {
33
35
  currentSiteDomain: SiteDomain | null;
34
36
  db: Database;
35
37
  hostedHandoff: HostedHandoffService;
38
+ rateLimiter: RateLimiter;
36
39
  services: Services;
37
40
  storage: StorageDriver | null;
38
41
  }
39
42
 
43
+ /**
44
+ * Single process-wide rate limiter for the Node runtime. Node serves all
45
+ * requests out of one persistent process, so in-memory counters are
46
+ * reliable and avoid per-request D1 round-trips. Constructed lazily on
47
+ * first use so tests that never build a request runtime don't pay for it.
48
+ */
49
+ let sharedNodeRateLimiter: RateLimiter | null = null;
50
+ function getNodeRateLimiter(): RateLimiter {
51
+ sharedNodeRateLimiter ??= createMemoryRateLimiter();
52
+ return sharedNodeRateLimiter;
53
+ }
54
+
40
55
  export interface NodeCliRuntime {
41
56
  currentSite: Site;
42
57
  currentSiteDomain: SiteDomain | null;
@@ -131,6 +146,7 @@ export async function createNodeRequestRuntime(
131
146
  schema: databaseSchema,
132
147
  secret: hostedControlPlaneSsoSecret,
133
148
  }),
149
+ rateLimiter: getNodeRateLimiter(),
134
150
  services: createServices(db, rawQuery, siteLookup.site.id, {
135
151
  databaseDialect,
136
152
  bootstrapSite: getSingleSiteBootstrapOptions(env),