@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,620 @@
1
+ /**
2
+ * Bun Runtime Adapters
3
+ *
4
+ * These adapters provide implementations for Bun environments
5
+ * using Bun's native SQLite, filesystem, and in-memory stores.
6
+ */
7
+
8
+ import type {
9
+ FirstResult,
10
+ IDatabase,
11
+ IObjectStorage,
12
+ IStaticAssets,
13
+ ListObjectsResult,
14
+ ObjectMetadata,
15
+ PreparedStatement,
16
+ QueryResult,
17
+ RunResult,
18
+ StorageObject,
19
+ } from "./types.ts";
20
+ import {
21
+ assertPathChainWithinBasePath,
22
+ isPathWithinBasePath,
23
+ resolvePathWithinBasePath,
24
+ } from "./shared.ts";
25
+ import { MemoryKV } from "./memory-kv.ts";
26
+ import { isBackendPath } from "../lib/backend-paths.ts";
27
+ import { loadBunSqlite } from "./compat-bun/types.ts";
28
+ import type { BunRuntime, BunSQLiteDatabase } from "./compat-bun/types.ts";
29
+ import path from "node:path";
30
+
31
+ declare const Bun: BunRuntime;
32
+ declare const require: (specifier: string) => unknown;
33
+
34
+ // Re-export MemoryKV as it works in Bun too.
35
+ export { MemoryKV };
36
+
37
+ const { mkdir, unlink, readdir, stat, realpath } = await import("fs/promises");
38
+
39
+ /**
40
+ * Drain a ReadableStream into a single Uint8Array.
41
+ */
42
+ async function drainStream(
43
+ stream: ReadableStream<Uint8Array>,
44
+ ): Promise<Uint8Array> {
45
+ const chunks: Uint8Array[] = [];
46
+ const reader = stream.getReader();
47
+ while (true) {
48
+ const { done, value } = await reader.read();
49
+ if (done) break;
50
+ chunks.push(value);
51
+ }
52
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
53
+ const result = new Uint8Array(totalLength);
54
+ let offset = 0;
55
+ for (const chunk of chunks) {
56
+ result.set(chunk, offset);
57
+ offset += chunk.length;
58
+ }
59
+ return result;
60
+ }
61
+
62
+ /**
63
+ * Convert a put() value to Uint8Array.
64
+ */
65
+ async function toUint8Array(
66
+ value: ReadableStream | ArrayBuffer | string,
67
+ ): Promise<Uint8Array> {
68
+ if (typeof value === "string") return new TextEncoder().encode(value);
69
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
70
+ return drainStream(value);
71
+ }
72
+
73
+ /**
74
+ * Read the JSON metadata sidecar for a storage key.
75
+ * Returns an empty object if the sidecar doesn't exist or can't be parsed.
76
+ */
77
+ async function readMetadata(metaPath: string): Promise<{
78
+ httpMetadata?: ObjectMetadata["httpMetadata"];
79
+ customMetadata?: Record<string, string>;
80
+ }> {
81
+ try {
82
+ const metaFile = Bun.file(metaPath);
83
+ if (await metaFile.exists()) {
84
+ return JSON.parse(await metaFile.text());
85
+ }
86
+ } catch {
87
+ // No metadata file or unreadable
88
+ }
89
+ return {};
90
+ }
91
+
92
+ /**
93
+ * Bun SQLite Database Adapter (using bun:sqlite)
94
+ */
95
+ export class BunDatabase implements IDatabase {
96
+ private db: BunSQLiteDatabase;
97
+
98
+ constructor(db: unknown) {
99
+ this.db = db as BunSQLiteDatabase;
100
+ }
101
+
102
+ static create(filename: string = ":memory:"): BunDatabase {
103
+ const Database = loadBunSqlite(require);
104
+ const db = new Database(filename);
105
+ db.exec("PRAGMA journal_mode = WAL");
106
+ // synchronous=NORMAL pairs with WAL to avoid an fsync on every statement.
107
+ // Set once at connection open (a connection-level setting) so a fresh
108
+ // self-host boot does not auto-commit+fsync per migration statement.
109
+ db.exec("PRAGMA synchronous = NORMAL");
110
+ // Foreign keys are intentionally left OFF (SQLite's per-connection default)
111
+ // so the Bun/libsql engine matches Cloudflare D1, which ignores the FK
112
+ // constraints declared in the migrations. Remote actors live in
113
+ // actor_cache (never in actors), yet objects.attributed_to / follows.* /
114
+ // likes.* / announces.* FK-reference actors(ap_id); enabling enforcement
115
+ // would make every inbound federated activity from a remote actor violate
116
+ // the FK and fail to insert. Referential cleanup is handled at the app
117
+ // level by deleteObjectCascade()/delete-cascade.ts, identically on D1.
118
+ return new BunDatabase(db);
119
+ }
120
+
121
+ prepare(query: string): PreparedStatement {
122
+ return new BunPreparedStatement(this.db, query);
123
+ }
124
+
125
+ getRawDatabase(): unknown {
126
+ return this.db;
127
+ }
128
+
129
+ async exec(query: string): Promise<void> {
130
+ this.db.exec(query);
131
+ }
132
+
133
+ async batch<T = unknown>(
134
+ statements: PreparedStatement[],
135
+ ): Promise<QueryResult<T>[]> {
136
+ const results: QueryResult<T>[] = [];
137
+ this.db.transaction(() => {
138
+ for (const stmt of statements) {
139
+ if (stmt instanceof BunPreparedStatement) {
140
+ const result = stmt.runSync();
141
+ results.push({
142
+ results: [] as T[],
143
+ success: true,
144
+ meta: { changes: result.changes },
145
+ });
146
+ }
147
+ }
148
+ })();
149
+ return results;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Bun SQLite Prepared Statement Adapter
155
+ */
156
+ class BunPreparedStatement implements PreparedStatement {
157
+ private db: BunSQLiteDatabase;
158
+ private query: string;
159
+ private boundValues: unknown[] = [];
160
+
161
+ constructor(db: BunSQLiteDatabase, query: string) {
162
+ this.db = db;
163
+ this.query = query;
164
+ }
165
+
166
+ bind(...values: unknown[]): PreparedStatement {
167
+ this.boundValues = values;
168
+ return this;
169
+ }
170
+
171
+ async first<T = unknown>(colName?: string): Promise<FirstResult<T>> {
172
+ const stmt = this.db.prepare(this.query);
173
+ const row = stmt.get(...this.boundValues) as Record<string, unknown> | null;
174
+ if (!row) return null;
175
+ if (colName) return row[colName] as T;
176
+ return row as T;
177
+ }
178
+
179
+ async all<T = unknown>(): Promise<QueryResult<T>> {
180
+ const stmt = this.db.prepare(this.query);
181
+ const rows = stmt.all(...this.boundValues) as T[];
182
+ return {
183
+ results: rows,
184
+ success: true,
185
+ };
186
+ }
187
+
188
+ async run(): Promise<RunResult> {
189
+ const result = this.runSync();
190
+ return {
191
+ success: true,
192
+ meta: {
193
+ changes: result.changes,
194
+ last_row_id: result.lastInsertRowid,
195
+ },
196
+ };
197
+ }
198
+
199
+ runSync(): { changes: number; lastInsertRowid: number } {
200
+ const stmt = this.db.prepare(this.query);
201
+ return stmt.run(...this.boundValues) as {
202
+ changes: number;
203
+ lastInsertRowid: number;
204
+ };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Bun Filesystem Storage Adapter
210
+ */
211
+ export class BunStorage implements IObjectStorage {
212
+ private basePath: string;
213
+ private realBasePath: string | null = null;
214
+
215
+ constructor(basePath: string) {
216
+ this.basePath = basePath;
217
+ }
218
+
219
+ static async create(basePath: string): Promise<BunStorage> {
220
+ await mkdir(basePath, { recursive: true });
221
+ return new BunStorage(basePath);
222
+ }
223
+
224
+ private getFilePath(key: string): string {
225
+ return resolvePathWithinBasePath(this.getResolvedBasePath(), key);
226
+ }
227
+
228
+ private getMetaPath(key: string): string {
229
+ return resolvePathWithinBasePath(
230
+ this.getResolvedBasePath(),
231
+ `${key}.meta.json`,
232
+ );
233
+ }
234
+
235
+ private getResolvedBasePath(): string {
236
+ return path.resolve(this.basePath);
237
+ }
238
+
239
+ private async getRealBasePath(): Promise<string> {
240
+ if (this.realBasePath) return this.realBasePath;
241
+ try {
242
+ await mkdir(this.getResolvedBasePath(), { recursive: true });
243
+ this.realBasePath = await realpath(this.getResolvedBasePath());
244
+ } catch {
245
+ this.realBasePath = this.getResolvedBasePath();
246
+ }
247
+ return this.realBasePath;
248
+ }
249
+
250
+ private async resolveExistingPath(filePath: string): Promise<string | null> {
251
+ try {
252
+ const realPath = await realpath(filePath);
253
+ const realBasePath = await this.getRealBasePath();
254
+ if (!isPathWithinBasePath(realBasePath, realPath)) {
255
+ throw new Error("Path escapes base directory");
256
+ }
257
+ return realPath;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ async put(
264
+ key: string,
265
+ value: ReadableStream | ArrayBuffer | string,
266
+ options?: {
267
+ httpMetadata?: ObjectMetadata["httpMetadata"];
268
+ customMetadata?: Record<string, string>;
269
+ },
270
+ ): Promise<void> {
271
+ const filePath = this.getFilePath(key);
272
+ const dir = path.dirname(filePath);
273
+
274
+ await assertPathChainWithinBasePath(
275
+ await this.getRealBasePath(),
276
+ filePath,
277
+ realpath,
278
+ );
279
+
280
+ await mkdir(dir, { recursive: true });
281
+
282
+ const realBasePath = await this.getRealBasePath();
283
+ let realFilePath: string | null = null;
284
+ try {
285
+ realFilePath = await realpath(filePath);
286
+ } catch {
287
+ realFilePath = null;
288
+ }
289
+ if (realFilePath) {
290
+ if (!isPathWithinBasePath(realBasePath, realFilePath)) {
291
+ throw new Error("Path escapes base directory");
292
+ }
293
+ } else {
294
+ const realDirPath = await realpath(dir);
295
+ if (!isPathWithinBasePath(realBasePath, realDirPath)) {
296
+ throw new Error("Path escapes base directory");
297
+ }
298
+ }
299
+
300
+ const content = await toUint8Array(value);
301
+ await Bun.write(filePath, content);
302
+
303
+ if (options?.httpMetadata || options?.customMetadata) {
304
+ await Bun.write(
305
+ this.getMetaPath(key),
306
+ JSON.stringify({
307
+ httpMetadata: options.httpMetadata,
308
+ customMetadata: options.customMetadata,
309
+ }),
310
+ );
311
+ }
312
+ }
313
+
314
+ async get(key: string): Promise<StorageObject | null> {
315
+ const filePath = this.getFilePath(key);
316
+
317
+ try {
318
+ const resolvedFilePath = await this.resolveExistingPath(filePath);
319
+ if (!resolvedFilePath) return null;
320
+ const file = Bun.file(resolvedFilePath);
321
+ if (!(await file.exists())) return null;
322
+
323
+ const content = new Uint8Array(await file.arrayBuffer());
324
+ const resolvedMetaPath = await this.resolveExistingPath(
325
+ this.getMetaPath(key),
326
+ );
327
+ const metadata = resolvedMetaPath
328
+ ? await readMetadata(resolvedMetaPath)
329
+ : {};
330
+
331
+ let bodyUsed = false;
332
+
333
+ return {
334
+ key,
335
+ body: new ReadableStream({
336
+ start(controller) {
337
+ controller.enqueue(content);
338
+ controller.close();
339
+ },
340
+ }),
341
+ bodyUsed,
342
+ arrayBuffer: async () => {
343
+ bodyUsed = true;
344
+ return content.buffer as ArrayBuffer;
345
+ },
346
+ text: async () => {
347
+ bodyUsed = true;
348
+ return new TextDecoder().decode(content);
349
+ },
350
+ json: async <T>() => {
351
+ bodyUsed = true;
352
+ return JSON.parse(new TextDecoder().decode(content)) as T;
353
+ },
354
+ httpMetadata: metadata.httpMetadata,
355
+ customMetadata: metadata.customMetadata,
356
+ };
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ async delete(key: string | string[]): Promise<void> {
363
+ const keys = Array.isArray(key) ? key : [key];
364
+ for (const k of keys) {
365
+ try {
366
+ const filePath = await this.resolveExistingPath(this.getFilePath(k));
367
+ if (filePath) await unlink(filePath);
368
+ } catch {
369
+ /* ignore */
370
+ }
371
+ try {
372
+ const metaPath = await this.resolveExistingPath(this.getMetaPath(k));
373
+ if (metaPath) await unlink(metaPath);
374
+ } catch {
375
+ /* ignore */
376
+ }
377
+ }
378
+ }
379
+
380
+ async list(options?: {
381
+ prefix?: string;
382
+ limit?: number;
383
+ cursor?: string;
384
+ delimiter?: string;
385
+ }): Promise<ListObjectsResult> {
386
+ const objects: ListObjectsResult["objects"] = [];
387
+ const realBasePath = await this.getRealBasePath();
388
+
389
+ const readDirRecursive = async (dir: string, prefix: string = "") => {
390
+ try {
391
+ const entries = await readdir(dir, { withFileTypes: true });
392
+ for (const entry of entries) {
393
+ const fullPath = `${dir}/${entry.name}`;
394
+ const realFullPath = await realpath(fullPath);
395
+ if (!isPathWithinBasePath(realBasePath, realFullPath)) continue;
396
+ const key = prefix ? `${prefix}/${entry.name}` : entry.name;
397
+
398
+ if (entry.isDirectory()) {
399
+ await readDirRecursive(fullPath, key);
400
+ } else if (!entry.name.endsWith(".meta.json")) {
401
+ if (!options?.prefix || key.startsWith(options.prefix)) {
402
+ const stats = await stat(fullPath);
403
+ objects.push({
404
+ key,
405
+ size: stats.size,
406
+ uploaded: stats.mtime,
407
+ });
408
+ }
409
+ }
410
+ }
411
+ } catch {
412
+ // Directory doesn't exist
413
+ }
414
+ };
415
+
416
+ await readDirRecursive(realBasePath);
417
+
418
+ const limit = options?.limit ?? 1000;
419
+ const truncated = objects.length > limit;
420
+
421
+ return {
422
+ objects: objects.slice(0, limit),
423
+ truncated,
424
+ cursor: truncated ? String(limit) : undefined,
425
+ };
426
+ }
427
+
428
+ async head(key: string): Promise<ObjectMetadata | null> {
429
+ const filePath = this.getFilePath(key);
430
+
431
+ try {
432
+ const resolvedFilePath = await this.resolveExistingPath(filePath);
433
+ if (!resolvedFilePath) return null;
434
+ const file = Bun.file(resolvedFilePath);
435
+ if (!(await file.exists())) return null;
436
+
437
+ const resolvedMetaPath = await this.resolveExistingPath(
438
+ this.getMetaPath(key),
439
+ );
440
+ const metadata = resolvedMetaPath
441
+ ? await readMetadata(resolvedMetaPath)
442
+ : {};
443
+ return {
444
+ contentLength: file.size,
445
+ httpMetadata: metadata.httpMetadata,
446
+ customMetadata: metadata.customMetadata,
447
+ };
448
+ } catch {
449
+ return null;
450
+ }
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Static file server for Bun
456
+ */
457
+ // Extension -> MIME fallback for static assets, used when Bun.file.type is
458
+ // empty. Mirrors the worker's getMimeType so the self-host (Bun) path serves
459
+ // the same Content-Types as the Cloudflare ASSETS binding.
460
+ const ASSET_MIME: Record<string, string> = {
461
+ ".html": "text/html; charset=utf-8",
462
+ ".js": "text/javascript; charset=utf-8",
463
+ ".mjs": "text/javascript; charset=utf-8",
464
+ ".css": "text/css; charset=utf-8",
465
+ ".json": "application/json; charset=utf-8",
466
+ // JSON-LD context documents (yurucommu.com/ns/*) need the ld+json media type
467
+ // so strict JSON-LD processors accept a dereferenced @context.
468
+ ".jsonld": "application/ld+json",
469
+ ".jsonl": "application/x-ndjson",
470
+ ".svg": "image/svg+xml",
471
+ ".png": "image/png",
472
+ ".jpg": "image/jpeg",
473
+ ".jpeg": "image/jpeg",
474
+ ".gif": "image/gif",
475
+ ".webp": "image/webp",
476
+ ".ico": "image/x-icon",
477
+ ".woff": "font/woff",
478
+ ".woff2": "font/woff2",
479
+ ".ttf": "font/ttf",
480
+ ".map": "application/json; charset=utf-8",
481
+ ".wasm": "application/wasm",
482
+ ".txt": "text/plain; charset=utf-8",
483
+ };
484
+
485
+ function mimeFromExt(filePath: string): string {
486
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
487
+ return ASSET_MIME[ext] || "application/octet-stream";
488
+ }
489
+
490
+ export class BunAssets implements IStaticAssets {
491
+ private basePath: string;
492
+ private realBasePath: string | null = null;
493
+
494
+ constructor(basePath: string) {
495
+ this.basePath = basePath;
496
+ }
497
+
498
+ static create(basePath: string): BunAssets {
499
+ return new BunAssets(basePath);
500
+ }
501
+
502
+ private getResolvedBasePath(): string {
503
+ return path.resolve(this.basePath);
504
+ }
505
+
506
+ private async getRealBasePath(): Promise<string> {
507
+ if (this.realBasePath) return this.realBasePath;
508
+ try {
509
+ this.realBasePath = await realpath(this.getResolvedBasePath());
510
+ } catch {
511
+ this.realBasePath = this.getResolvedBasePath();
512
+ }
513
+ return this.realBasePath;
514
+ }
515
+
516
+ async fetch(request: Request): Promise<Response> {
517
+ const url = new URL(request.url);
518
+ let filePath: string;
519
+ try {
520
+ filePath = resolvePathWithinBasePath(
521
+ this.getResolvedBasePath(),
522
+ `.${url.pathname}`,
523
+ );
524
+ } catch {
525
+ return new Response("Forbidden", { status: 403 });
526
+ }
527
+
528
+ const realBasePath = await this.getRealBasePath();
529
+
530
+ // A missing path falls back to the SPA's index.html ONLY when it is a
531
+ // genuine CLIENT-SIDE route (/, /search, /profile, /post/<id>, ...), so a
532
+ // deep link / refresh / shared URL loads the app instead of 404ing. It must
533
+ // NOT serve the SPA when the path is either:
534
+ // - a real static ASSET request (a known asset extension) — a missing
535
+ // asset is a genuine 404, not the HTML shell; or
536
+ // - a BACKEND route prefix (/api, /ap, /.well-known, ...) — reaching the
537
+ // static handler means the API/AP route did not match, which an API/AP
538
+ // client expects as a 404, never an HTML 200.
539
+ const lastDot = url.pathname.lastIndexOf(".");
540
+ const ext = lastDot >= 0 ? url.pathname.slice(lastDot).toLowerCase() : "";
541
+ const hasAssetExt = ext !== "" && ext !== ".html" && ext in ASSET_MIME;
542
+ const spaFallbackEligible = !hasAssetExt && !isBackendPath(url.pathname);
543
+
544
+ let realFilePath: string;
545
+ try {
546
+ realFilePath = await realpath(filePath);
547
+ } catch {
548
+ // Path does not exist: a client route falls back to the SPA shell; a real
549
+ // asset or an unmatched backend route is a genuine 404.
550
+ return spaFallbackEligible
551
+ ? this.serveSpaIndex(realBasePath)
552
+ : new Response("Not Found", { status: 404 });
553
+ }
554
+
555
+ try {
556
+ if (!isPathWithinBasePath(realBasePath, realFilePath)) {
557
+ return new Response("Forbidden", { status: 403 });
558
+ }
559
+
560
+ const stats = await stat(realFilePath);
561
+ let servePath = realFilePath;
562
+ let file = Bun.file(realFilePath);
563
+
564
+ // If directory, serve its index.html.
565
+ if (stats.isDirectory()) {
566
+ const realIndexPath = await realpath(
567
+ path.join(realFilePath, "index.html"),
568
+ );
569
+ if (!isPathWithinBasePath(realBasePath, realIndexPath)) {
570
+ return new Response("Forbidden", { status: 403 });
571
+ }
572
+ servePath = realIndexPath;
573
+ file = Bun.file(servePath);
574
+ }
575
+
576
+ if (await file.exists()) {
577
+ // Set Content-Type explicitly from the file extension: the global
578
+ // response pipeline emits X-Content-Type-Options: nosniff, so a missing
579
+ // type makes the browser refuse to render the SPA / execute its module
580
+ // scripts. (new Response(Bun.file) does not reliably propagate a type
581
+ // through the Hono pipeline here, so set it ourselves.)
582
+ return new Response(file, {
583
+ headers: { "Content-Type": mimeFromExt(servePath) },
584
+ });
585
+ }
586
+
587
+ return spaFallbackEligible
588
+ ? this.serveSpaIndex(realBasePath)
589
+ : new Response("Not Found", { status: 404 });
590
+ } catch {
591
+ return spaFallbackEligible
592
+ ? this.serveSpaIndex(realBasePath)
593
+ : new Response("Not Found", { status: 404 });
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Serve the SPA shell (index.html) for client-side routes. Returns 404 only
599
+ * if the bundle's index.html is genuinely missing.
600
+ */
601
+ private async serveSpaIndex(realBasePath: string): Promise<Response> {
602
+ try {
603
+ const realIndexPath = await realpath(
604
+ path.join(this.getResolvedBasePath(), "index.html"),
605
+ );
606
+ if (!isPathWithinBasePath(realBasePath, realIndexPath)) {
607
+ return new Response("Forbidden", { status: 403 });
608
+ }
609
+ const indexFile = Bun.file(realIndexPath);
610
+ if (await indexFile.exists()) {
611
+ return new Response(indexFile, {
612
+ headers: { "Content-Type": "text/html; charset=utf-8" },
613
+ });
614
+ }
615
+ } catch {
616
+ // index.html missing/unreadable — fall through to 404.
617
+ }
618
+ return new Response("Not Found", { status: 404 });
619
+ }
620
+ }