@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.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- 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
|
+
}
|