@takosjp/yurucommu-core 3.0.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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Bun Server Entry Point (unified)
3
+ *
4
+ * This file starts the yurucommu backend on Bun.
5
+ *
6
+ * Usage:
7
+ * bun src/backend/server.ts
8
+ *
9
+ * Environment variables:
10
+ * PORT - Server port (default: 3000)
11
+ * DATABASE_PATH - SQLite database path (default: ./data/yurucommu.db)
12
+ * STORAGE_PATH - File storage path (default: ./data/storage)
13
+ * ASSETS_PATH - Static assets path (default: ./dist)
14
+ * APP_URL - Application URL (default: http://localhost:3000)
15
+ * AUTH_PASSWORD_HASH - PBKDF2-hashed password authentication
16
+ * GOOGLE_CLIENT_ID/SECRET - Google OAuth
17
+ * X_CLIENT_ID/SECRET - X (Twitter) OAuth
18
+ * OIDC_ISSUER_URL / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET - OIDC login
19
+ * TAKOS_URL - Optional Takos API base URL for proxy/tool integration
20
+ */
21
+
22
+ import type { Message, MessageBatch, Queue } from "@cloudflare/workers-types";
23
+ import { mkdir, readdir, readFile, stat } from "node:fs/promises";
24
+ import process from "node:process";
25
+ import { and, inArray, lt, or } from "drizzle-orm";
26
+ import { BunAssets, BunDatabase, BunStorage } from "./runtime/bun.ts";
27
+ import { MemoryKV } from "./runtime/memory-kv.ts";
28
+ import type { Env } from "./types.ts";
29
+ import type {
30
+ DeliveryDlqMessageV1,
31
+ DeliveryQueueMessageV1,
32
+ } from "./lib/delivery/types.ts";
33
+ import { buildDeliverEndpointMessage } from "./lib/delivery/queue.ts";
34
+ import { deliveryQueue, getDbSQLite } from "../db/index.ts";
35
+ import { logger } from "./lib/logger.ts";
36
+
37
+ const log = logger.child({ component: "server.bootstrap" });
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // sqlite3 type definitions used by the local database adapter
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** Minimal interface for a sqlite3 prepared statement. */
44
+ interface LocalSqlite3Statement {
45
+ get(...params: unknown[]): Record<string, unknown> | undefined;
46
+ all(...params: unknown[]): Record<string, unknown>[];
47
+ run(...params: unknown[]): void;
48
+ finalize?(): void;
49
+ }
50
+
51
+ /** Minimal interface for a sqlite3 database. */
52
+ interface LocalSqlite3Database {
53
+ exec(sql: string): void;
54
+ prepare(sql: string): LocalSqlite3Statement;
55
+ transaction<T>(fn: () => T): () => T;
56
+ changes: number;
57
+ lastInsertRowId: number;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Runtime env helpers
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const PORT = parseInt(process.env.PORT ?? "3000", 10);
65
+ const DATABASE_PATH = process.env.DATABASE_PATH ?? "./data/yurucommu.db";
66
+ const STORAGE_PATH = process.env.STORAGE_PATH ?? "./data/storage";
67
+ const ASSETS_PATH = process.env.ASSETS_PATH ?? "./dist";
68
+ const MIGRATIONS_PATH = process.env.MIGRATIONS_PATH ?? "./migrations";
69
+ const APP_URL = process.env.APP_URL ?? `http://localhost:${PORT}`;
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Create Cloudflare-compatible environment from the local runtime
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const ENV_PASSTHROUGH_KEYS = [
76
+ "AUTH_PASSWORD_HASH",
77
+ "ENCRYPTION_KEY",
78
+ "GOOGLE_CLIENT_ID",
79
+ "GOOGLE_CLIENT_SECRET",
80
+ "X_CLIENT_ID",
81
+ "X_CLIENT_SECRET",
82
+ "OIDC_ISSUER_URL",
83
+ "OIDC_CLIENT_ID",
84
+ "OIDC_CLIENT_SECRET",
85
+ "OAUTH_ISSUER_URL",
86
+ "TAKOSUMI_ACCOUNTS_ISSUER_URL",
87
+ "TAKOSUMI_ACCOUNTS_CLIENT_ID",
88
+ "TAKOSUMI_ACCOUNTS_CLIENT_SECRET",
89
+ "TAKOS_URL",
90
+ "AUTH_MODE",
91
+ "CSRF_ALLOWED_ORIGINS",
92
+ "ENABLE_TAKOS_TOOLS",
93
+ "DELIVERY_SHADOW_PROBE_HOSTS",
94
+ "DELIVERY_SHADOW_PROBE_SAMPLE_RATE",
95
+ "DELIVERY_QUEUE_NAME",
96
+ "DELIVERY_DLQ_NAME",
97
+ "YURUCOMMU_ENABLE_LOCAL_SUBSTRATE_REMOTE_FETCHES",
98
+ "YURUCOMMU_ENABLE_LOCAL_DELIVERY_QUEUE",
99
+ ] as const;
100
+
101
+ function isTruthyEnv(value: string | undefined): boolean {
102
+ if (!value) return false;
103
+ return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
104
+ }
105
+
106
+ type LocalQueueBody = DeliveryQueueMessageV1 | DeliveryDlqMessageV1;
107
+
108
+ type LocalQueueSendOptions = {
109
+ delaySeconds?: number;
110
+ };
111
+
112
+ type LocalQueueBatchItem<T> = {
113
+ body: T;
114
+ delaySeconds?: number;
115
+ };
116
+
117
+ function createLocalMessageBatch<T extends LocalQueueBody>(
118
+ queueName: string,
119
+ bodies: T[],
120
+ requeue: (body: T, delaySeconds?: number) => void,
121
+ ): MessageBatch<T> {
122
+ const messages = bodies.map((body): Message<T> => {
123
+ let settled = false;
124
+ return {
125
+ id: crypto.randomUUID(),
126
+ timestamp: new Date(),
127
+ attempts: 1,
128
+ body,
129
+ ack: () => {
130
+ settled = true;
131
+ },
132
+ retry: (options?: { delaySeconds?: number }) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ requeue(body, options?.delaySeconds);
136
+ },
137
+ } as Message<T>;
138
+ });
139
+
140
+ return {
141
+ queue: queueName,
142
+ messages,
143
+ ackAll: () => {
144
+ for (const message of messages) message.ack();
145
+ },
146
+ retryAll: (options?: { delaySeconds?: number }) => {
147
+ for (const message of messages) message.retry(options);
148
+ },
149
+ } as unknown as MessageBatch<T>;
150
+ }
151
+
152
+ function createLocalQueue<T extends LocalQueueBody>(
153
+ env: LocalServerEnv,
154
+ queueName: string,
155
+ ): Queue<T> {
156
+ const pending: T[] = [];
157
+ let draining = false;
158
+ let drainScheduled = false;
159
+
160
+ const enqueue = (body: T, delaySeconds?: number) => {
161
+ if (delaySeconds && delaySeconds > 0) {
162
+ setTimeout(() => enqueue(body), delaySeconds * 1000);
163
+ return;
164
+ }
165
+ pending.push(body);
166
+ scheduleDrain();
167
+ };
168
+
169
+ const drain = async () => {
170
+ if (draining) return;
171
+ draining = true;
172
+ try {
173
+ while (pending.length > 0) {
174
+ const batchBodies = pending.splice(0, 100);
175
+ const { handleYurucommuQueueBatch } = await import("./index.ts");
176
+ await handleYurucommuQueueBatch(
177
+ createLocalMessageBatch(queueName, batchBodies, enqueue),
178
+ env,
179
+ );
180
+ }
181
+ } catch (error) {
182
+ log.error("Local delivery queue drain failed", {
183
+ event: "server.local_delivery_queue.drain_failed",
184
+ queueName,
185
+ error,
186
+ });
187
+ } finally {
188
+ draining = false;
189
+ if (pending.length > 0) scheduleDrain();
190
+ }
191
+ };
192
+
193
+ function scheduleDrain() {
194
+ if (drainScheduled) return;
195
+ drainScheduled = true;
196
+ queueMicrotask(() => {
197
+ drainScheduled = false;
198
+ void drain();
199
+ });
200
+ }
201
+
202
+ return {
203
+ send: async (body: T, options?: LocalQueueSendOptions) => {
204
+ enqueue(body, options?.delaySeconds);
205
+ },
206
+ sendBatch: async (messages: Array<LocalQueueBatchItem<T>>) => {
207
+ for (const message of messages) {
208
+ enqueue(message.body, message.delaySeconds);
209
+ }
210
+ },
211
+ } as unknown as Queue<T>;
212
+ }
213
+
214
+ function attachLocalDeliveryQueues(env: LocalServerEnv): void {
215
+ const deliveryQueueName = env.DELIVERY_QUEUE_NAME ?? "yurucommu-delivery";
216
+ const deliveryDlqName = env.DELIVERY_DLQ_NAME ?? "yurucommu-delivery-dlq";
217
+ env.DELIVERY_QUEUE = createLocalQueue<DeliveryQueueMessageV1>(
218
+ env,
219
+ deliveryQueueName,
220
+ );
221
+ env.DELIVERY_DLQ = createLocalQueue<DeliveryDlqMessageV1>(
222
+ env,
223
+ deliveryDlqName,
224
+ );
225
+ log.info("Enabled local delivery queue bindings", {
226
+ event: "server.local_delivery_queue.enabled",
227
+ deliveryQueueName,
228
+ deliveryDlqName,
229
+ });
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Local delivery queue durability: reconciliation sweep
234
+ // ---------------------------------------------------------------------------
235
+ //
236
+ // The in-memory local queue (createLocalQueue) holds queued/retry-waiting
237
+ // deliveries only in process memory + setTimeout timers. On the supported
238
+ // bun/node-postgres self-host path a restart loses every in-flight delivery.
239
+ //
240
+ // The delivery_queue table is the durable record of work that still needs to
241
+ // run, so we reconcile it back into the in-memory queue on startup and on a
242
+ // periodic interval. We re-enqueue every row whose terminal disposition has
243
+ // not been reached:
244
+ // - pending / retry_wait: enqueued or waiting for a retry that the lost
245
+ // setTimeout would otherwise never fire.
246
+ // - processing older than the stale threshold: a delivery that was claimed
247
+ // by a worker that died before acking/finishing (queue-delivery.ts treats
248
+ // such rows as reclaimable via STALE_PROCESSING_MS).
249
+ // Rows in delivered / dead_letter / failed are terminal and skipped.
250
+ //
251
+ // This is guarded to the local/bun queue path only — under Cloudflare Queues
252
+ // the platform persists in-flight messages and re-delivers them itself, so the
253
+ // sweep must not run there.
254
+
255
+ /** Statuses that still represent work the local queue must (re)drive. */
256
+ const RECONCILE_PENDING_STATUSES = ["pending", "retry_wait"] as const;
257
+
258
+ /**
259
+ * Matches queue-delivery.ts STALE_PROCESSING_MS: a row marked `processing`
260
+ * older than this lost its owning worker and is safe to re-enqueue.
261
+ */
262
+ const RECONCILE_STALE_PROCESSING_MS = 2 * 60 * 1000;
263
+
264
+ /** How often the periodic reconciliation sweep runs. */
265
+ const RECONCILE_SWEEP_INTERVAL_MS = 60 * 1000;
266
+
267
+ /** How many rows to re-enqueue per sweep pass. */
268
+ const RECONCILE_SWEEP_BATCH = 500;
269
+
270
+ /**
271
+ * Select non-terminal delivery_queue rows and re-enqueue them onto the local
272
+ * delivery queue. Returns the number of rows re-enqueued.
273
+ */
274
+ export async function reconcileLocalDeliveryQueue(
275
+ env: LocalServerEnv,
276
+ ): Promise<number> {
277
+ const queue = env.DELIVERY_QUEUE;
278
+ if (!queue) return 0;
279
+
280
+ const db = env.DB_INSTANCE;
281
+ const staleBefore = new Date(
282
+ Date.now() - RECONCILE_STALE_PROCESSING_MS,
283
+ ).toISOString();
284
+
285
+ const rows = await db
286
+ .select({ id: deliveryQueue.id })
287
+ .from(deliveryQueue)
288
+ .where(
289
+ or(
290
+ inArray(deliveryQueue.status, [...RECONCILE_PENDING_STATUSES]),
291
+ and(
292
+ inArray(deliveryQueue.status, ["processing"]),
293
+ lt(deliveryQueue.processingStartedAt, staleBefore),
294
+ ),
295
+ ),
296
+ )
297
+ .limit(RECONCILE_SWEEP_BATCH);
298
+
299
+ if (rows.length === 0) return 0;
300
+
301
+ for (const row of rows) {
302
+ await queue.send(buildDeliverEndpointMessage(row.id));
303
+ }
304
+ return rows.length;
305
+ }
306
+
307
+ /**
308
+ * Run the reconciliation sweep once at startup and then on a periodic
309
+ * interval. Local/bun queue path only. Errors are logged and swallowed so a
310
+ * transient DB hiccup never tears down the server.
311
+ */
312
+ function startLocalDeliveryQueueReconciler(env: LocalServerEnv): void {
313
+ const runSweep = async (trigger: "startup" | "interval") => {
314
+ try {
315
+ const requeued = await reconcileLocalDeliveryQueue(env);
316
+ if (requeued > 0) {
317
+ log.info("Reconciled local delivery queue", {
318
+ event: "server.local_delivery_queue.reconciled",
319
+ trigger,
320
+ requeued,
321
+ });
322
+ }
323
+ } catch (error) {
324
+ log.error("Local delivery queue reconciliation failed", {
325
+ event: "server.local_delivery_queue.reconcile_failed",
326
+ trigger,
327
+ error,
328
+ });
329
+ }
330
+ };
331
+
332
+ void runSweep("startup");
333
+ const timer = setInterval(() => {
334
+ void runSweep("interval");
335
+ }, RECONCILE_SWEEP_INTERVAL_MS);
336
+ // Do not keep the event loop alive solely for the reconciler.
337
+ (timer as { unref?: () => void }).unref?.();
338
+ }
339
+
340
+ async function createLocalServerEnv(config: {
341
+ databasePath: string;
342
+ storagePath: string;
343
+ assetsPath: string;
344
+ appUrl: string;
345
+ }): Promise<{ env: LocalServerEnv; rawDb: BunDatabase }> {
346
+ const db = BunDatabase.create(config.databasePath);
347
+ const kv = new MemoryKV();
348
+ const assets = BunAssets.create(config.assetsPath);
349
+ const media = await BunStorage.create(config.storagePath);
350
+
351
+ const passthrough: Record<string, string | undefined> = {};
352
+ for (const key of ENV_PASSTHROUGH_KEYS) {
353
+ passthrough[key] = process.env[key];
354
+ }
355
+
356
+ const dbInstance = await getDbSQLite(config.databasePath);
357
+ const env: LocalServerEnv = {
358
+ DB_INSTANCE: dbInstance,
359
+ MEDIA: media,
360
+ KV: kv,
361
+ ASSETS: assets,
362
+ APP_URL: config.appUrl,
363
+ ...passthrough,
364
+ };
365
+
366
+ if (isTruthyEnv(process.env.YURUCOMMU_ENABLE_LOCAL_DELIVERY_QUEUE)) {
367
+ attachLocalDeliveryQueues(env);
368
+ env.__localDeliveryQueueEnabled = true;
369
+ }
370
+
371
+ return { env, rawDb: db };
372
+ }
373
+
374
+ type LocalServerEnv = Pick<
375
+ Env,
376
+ "DB_INSTANCE" | "MEDIA" | "KV" | "ASSETS" | "DELIVERY_QUEUE" | "DELIVERY_DLQ"
377
+ > & {
378
+ APP_URL: string;
379
+ /**
380
+ * Set when the in-memory local delivery queue path is active (bun/node-
381
+ * postgres self-host). Gates the durability reconciliation sweep so it never
382
+ * runs on the Cloudflare Queues path.
383
+ */
384
+ __localDeliveryQueueEnabled?: boolean;
385
+ } & Partial<Record<(typeof ENV_PASSTHROUGH_KEYS)[number], string | undefined>>;
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Run migrations from SQL files
389
+ // ---------------------------------------------------------------------------
390
+
391
+ export async function runMigrations(
392
+ db: BunDatabase,
393
+ migrationsDir: string,
394
+ ): Promise<void> {
395
+ const entries: string[] = [];
396
+ for (const entry of await readdir(migrationsDir, { withFileTypes: true })) {
397
+ if (entry.isFile() && entry.name.endsWith(".sql")) {
398
+ entries.push(entry.name);
399
+ }
400
+ }
401
+ entries.sort();
402
+
403
+ const rawDb = db.getRawDatabase() as LocalSqlite3Database;
404
+
405
+ // Throughput note: the connection is opened with WAL + synchronous=NORMAL
406
+ // (see BunDatabase.create), so each per-migration transaction below commits
407
+ // with a single fsync instead of one fsync per statement — what made a fresh
408
+ // self-host boot take minutes.
409
+
410
+ rawDb.exec(`
411
+ CREATE TABLE IF NOT EXISTS yurucommu_migrations (
412
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
413
+ name TEXT UNIQUE NOT NULL,
414
+ applied_at TEXT DEFAULT (datetime('now'))
415
+ )
416
+ `);
417
+
418
+ const appliedStmt = rawDb.prepare("SELECT name FROM yurucommu_migrations");
419
+ let applied: Array<{ name: string }>;
420
+ try {
421
+ applied = appliedStmt.all() as Array<{ name: string }>;
422
+ } finally {
423
+ appliedStmt.finalize?.();
424
+ }
425
+ const appliedSet = new Set(applied.map((r) => r.name));
426
+
427
+ for (const file of entries) {
428
+ if (appliedSet.has(file)) {
429
+ log.debug("Migration already applied, skipping", {
430
+ event: "server.migration.skipped",
431
+ migration: file,
432
+ });
433
+ continue;
434
+ }
435
+
436
+ log.info("Applying migration", {
437
+ event: "server.migration.applying",
438
+ migration: file,
439
+ });
440
+ const sql = await readFile(`${migrationsDir}/${file}`, "utf8");
441
+
442
+ // If a migration file manages its own transaction, do not wrap it again —
443
+ // nesting BEGIN inside an open transaction is a SQLite error. Detect a
444
+ // leading BEGIN [IMMEDIATE|DEFERRED|EXCLUSIVE|TRANSACTION] anywhere in the
445
+ // file (ignoring SQL line comments / leading whitespace).
446
+ const ownsTransaction =
447
+ /(^|\n)\s*BEGIN(\s+(IMMEDIATE|DEFERRED|EXCLUSIVE|TRANSACTION))?\s*;/i.test(
448
+ sql,
449
+ );
450
+
451
+ // Wrap the schema change AND its migration ledger bookkeeping in one
452
+ // transaction so the whole file commits with a single fsync, and so a
453
+ // failure rolls back both the schema and the tracking record together.
454
+ // Use the driver's transaction() wrapper (BEGIN/COMMIT/ROLLBACK handled
455
+ // internally, committing with one fsync) rather than issuing BEGIN/COMMIT
456
+ // as separate exec scripts. If the migration file manages its own
457
+ // transaction, run it directly to avoid nesting BEGIN inside an open
458
+ // transaction (a SQLite error).
459
+ const applyMigration = () => {
460
+ rawDb.exec(sql);
461
+
462
+ const markAppliedStmt = rawDb.prepare(
463
+ "INSERT INTO yurucommu_migrations (name) VALUES (?)",
464
+ );
465
+ try {
466
+ markAppliedStmt.run(file);
467
+ } finally {
468
+ markAppliedStmt.finalize?.();
469
+ }
470
+ };
471
+
472
+ try {
473
+ if (ownsTransaction) {
474
+ applyMigration();
475
+ } else {
476
+ rawDb.transaction(applyMigration)();
477
+ }
478
+ } catch (e) {
479
+ log.error("Error executing migration", {
480
+ event: "server.migration.error",
481
+ migration: file,
482
+ error: e,
483
+ });
484
+ throw e;
485
+ }
486
+
487
+ log.info("Migration applied successfully", {
488
+ event: "server.migration.applied",
489
+ migration: file,
490
+ });
491
+ }
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Main
496
+ // ---------------------------------------------------------------------------
497
+
498
+ async function main() {
499
+ log.info("Starting Yurucommu server (Bun mode)", {
500
+ event: "server.bootstrap.start",
501
+ mode: "bun",
502
+ });
503
+
504
+ // Ensure data directory exists
505
+ const dataDir = DATABASE_PATH.substring(0, DATABASE_PATH.lastIndexOf("/"));
506
+ try {
507
+ await mkdir(dataDir, { recursive: true });
508
+ } catch {
509
+ /* ignore if exists */
510
+ }
511
+
512
+ const { env, rawDb } = await createLocalServerEnv({
513
+ databasePath: DATABASE_PATH,
514
+ storagePath: STORAGE_PATH,
515
+ assetsPath: ASSETS_PATH,
516
+ appUrl: APP_URL,
517
+ });
518
+
519
+ // Run migrations
520
+ try {
521
+ await stat(MIGRATIONS_PATH);
522
+ } catch (error) {
523
+ if (!isNotFoundError(error)) {
524
+ throw error;
525
+ }
526
+ log.info("No migrations directory found, skipping migrations", {
527
+ event: "server.migrations.absent",
528
+ migrationsPath: MIGRATIONS_PATH,
529
+ });
530
+ await startServer(env);
531
+ return;
532
+ }
533
+
534
+ log.info("Running database migrations", {
535
+ event: "server.migrations.running",
536
+ migrationsPath: MIGRATIONS_PATH,
537
+ });
538
+ await runMigrations(rawDb, MIGRATIONS_PATH);
539
+ log.info("Migrations complete", { event: "server.migrations.complete" });
540
+
541
+ await startServer(env);
542
+ }
543
+
544
+ async function startServer(env: LocalServerEnv) {
545
+ log.info("Server starting", {
546
+ event: "server.bootstrap.starting",
547
+ port: PORT,
548
+ appUrl: APP_URL,
549
+ databasePath: DATABASE_PATH,
550
+ storagePath: STORAGE_PATH,
551
+ assetsPath: ASSETS_PATH,
552
+ });
553
+
554
+ const { backendApp } = await import("./index.ts");
555
+
556
+ bunLike().serve({
557
+ port: PORT,
558
+ fetch: (request: Request, server?: BunServerLike) => {
559
+ // Stamp the authentic TCP peer address onto the (server-side, never
560
+ // client-controllable) ExecutionContext props. getClientIP uses it as a
561
+ // last resort so a directly-exposed self-host does not collapse every
562
+ // caller into one "unknown" rate-limit / login-lockout bucket (which would
563
+ // let any attacker DoS the single owner's login). It is NOT a header, so a
564
+ // client cannot forge it; behind a reverse proxy it is the proxy's address
565
+ // (set TAKOS_TRUST_PROXY to honour X-Forwarded-For instead).
566
+ const socketIp = server?.requestIP?.(request)?.address;
567
+ const ctx: ExecutionContext = {
568
+ waitUntil: (promise: Promise<unknown>) => {
569
+ promise.catch((error) => {
570
+ log.error("Background task failed", {
571
+ event: "server.background_task.failed",
572
+ error,
573
+ });
574
+ });
575
+ },
576
+ passThroughOnException: () => {},
577
+ props: socketIp ? { socketIp } : {},
578
+ };
579
+ return backendApp.fetch(request, env, ctx);
580
+ },
581
+ });
582
+
583
+ log.info("Server is running", {
584
+ event: "server.bootstrap.running",
585
+ port: PORT,
586
+ appUrl: APP_URL,
587
+ });
588
+
589
+ // Local/bun queue path only: recover in-flight deliveries lost across a
590
+ // restart by reconciling the durable delivery_queue table back into the
591
+ // in-memory queue (startup sweep + periodic re-sweep). The Cloudflare Queues
592
+ // path persists in-flight messages itself, so this is gated behind the
593
+ // local-queue flag.
594
+ if (env.__localDeliveryQueueEnabled && env.DELIVERY_QUEUE) {
595
+ startLocalDeliveryQueueReconciler(env);
596
+ }
597
+ }
598
+
599
+ if (import.meta.main) {
600
+ main().catch((error) => {
601
+ log.error("Failed to start server", {
602
+ event: "server.bootstrap.failed",
603
+ error,
604
+ });
605
+ process.exit(1);
606
+ });
607
+ }
608
+
609
+ type BunServerLike = {
610
+ requestIP?: (request: Request) => { address?: string } | null;
611
+ };
612
+
613
+ type BunLike = {
614
+ serve(options: {
615
+ port: number;
616
+ fetch: (
617
+ request: Request,
618
+ server?: BunServerLike,
619
+ ) => Response | Promise<Response>;
620
+ }): unknown;
621
+ };
622
+
623
+ function bunLike(): BunLike {
624
+ const bun = (globalThis as { Bun?: BunLike }).Bun;
625
+ if (!bun) throw new Error("Bun runtime is required to start yurucommu");
626
+ return bun;
627
+ }
628
+
629
+ function isNotFoundError(error: unknown): boolean {
630
+ return (
631
+ typeof error === "object" &&
632
+ error !== null &&
633
+ "code" in error &&
634
+ (error as { code?: unknown }).code === "ENOENT"
635
+ );
636
+ }