@takosjp/yurucommu-core 3.4.5 → 4.1.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/README.en.md CHANGED
@@ -87,6 +87,9 @@ Takos core. See [`AGENTS.md`](AGENTS.md) for the full product boundary.
87
87
 
88
88
  ## Documentation
89
89
 
90
+ - [Runtime lanes](docs/design/runtime-lanes.md) — how the bindings differ between
91
+ a raw-Cloudflare deployment and one on a host that projects the portable
92
+ facades, and how a deployment declares which it is
90
93
  - [Deployment guide](https://yurucommu.com/help/deployment.html)
91
94
  - [Getting started](https://yurucommu.com/help/getting-started.html)
92
95
  - [Help site](https://yurucommu.com/help/)
package/README.md CHANGED
@@ -86,6 +86,8 @@ Yurucommu は ActivityPub 連合・コンテンツ配送・ユーザー identity
86
86
 
87
87
  ## ドキュメント
88
88
 
89
+ - [Runtime lanes](docs/design/runtime-lanes.md) — raw Cloudflare binding と
90
+ portable facade で binding の形が変わる点と、その宣言方法
89
91
  - [Deployment guide](https://yurucommu.com/help/deployment.html)
90
92
  - [Getting started](https://yurucommu.com/help/getting-started.html)
91
93
  - [Help site](https://yurucommu.com/help/)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "3.4.5",
3
+ "version": "4.1.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "3.4.5",
3
+ "version": "4.1.0",
4
4
  "description": "Typed client SDK and public API contract for yurucommu-server clients.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -5,9 +5,12 @@ import type { Env, EnvVars, Variables } from "./types.ts";
5
5
  import { extractActorFromSession } from "./lib/session-actor.ts";
6
6
  import { isBackendPath } from "./lib/backend-paths.ts";
7
7
  import {
8
- wrapCloudflareBindings,
9
- wrapCloudflareMessageBatch,
10
- } from "./runtime/cloudflare.ts";
8
+ resolveRuntimeLane,
9
+ wrapRuntimeBindings,
10
+ wrapRuntimeMessageBatch,
11
+ type PortableWorkerBindings,
12
+ } from "./runtime/lane.ts";
13
+ import type { EdgeQueueBatch } from "./runtime/edge-facades.ts";
11
14
  import {
12
15
  getMobileOidcAudience,
13
16
  getOidcClientCredentials,
@@ -992,15 +995,17 @@ export async function handleYurucommuQueueBatch(
992
995
  batch.ackAll();
993
996
  }
994
997
 
995
- type WorkerBindings = EnvVars & {
996
- DB: D1Database;
997
- MEDIA?: R2Bucket;
998
- KV: KVNamespace;
998
+ /**
999
+ * Bindings that are not the runtime ports, and so pass through whichever lane
1000
+ * wrapper runs. Durable Objects live here: Takoform's Worker Version form has
1001
+ * no Durable Object binding, so on the portable lane both are simply unbound
1002
+ * and the routes that need them answer 503, exactly as on a Cloudflare
1003
+ * deployment that did not declare them.
1004
+ */
1005
+ type PassthroughBindings = EnvVars & {
999
1006
  ASSETS?: Fetcher;
1000
- DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
1001
- DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
1002
- // Signaling hub Durable Object namespace (call feature). wrapCloudflareBindings
1003
- // spreads it through untouched (it is not DB/MEDIA/KV/ASSETS) so app code and
1007
+ // Signaling hub Durable Object namespace (call feature). The lane wrappers
1008
+ // spread it through untouched (it is not DB/MEDIA/KV/ASSETS) so app code and
1004
1009
  // the rtc routes read it as c.env.CALL_SIGNALING.
1005
1010
  CALL_SIGNALING?: DurableObjectNamespace;
1006
1011
  // Per-user realtime event stream Durable Object namespace. Same pass-through
@@ -1009,6 +1014,26 @@ type WorkerBindings = EnvVars & {
1009
1014
  REALTIME_STREAM?: DurableObjectNamespace;
1010
1015
  };
1011
1016
 
1017
+ /**
1018
+ * What this Worker may be handed.
1019
+ *
1020
+ * Raw Cloudflare bindings, or the portable facades a wrapper host projects.
1021
+ * `wrapRuntimeBindings` decides which by reading the deployment's declared
1022
+ * `YURUCOMMU_RUNTIME_LANE` and proving it against the bindings that actually
1023
+ * arrived; see runtime/lane.ts.
1024
+ */
1025
+ type WorkerBindings = PassthroughBindings &
1026
+ (
1027
+ | {
1028
+ DB: D1Database;
1029
+ MEDIA?: R2Bucket;
1030
+ KV: KVNamespace;
1031
+ DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
1032
+ DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
1033
+ }
1034
+ | PortableWorkerBindings
1035
+ );
1036
+
1012
1037
  function isMaterializedRuntimeEnv(
1013
1038
  bindings: WorkerBindings | Env,
1014
1039
  ): bindings is Env {
@@ -1021,16 +1046,19 @@ export default {
1021
1046
  bindings: WorkerBindings,
1022
1047
  ctx: ExecutionContext,
1023
1048
  ): Promise<Response> {
1024
- return app.fetch(request, wrapCloudflareBindings(bindings), ctx);
1049
+ return app.fetch(request, wrapRuntimeBindings(bindings), ctx);
1025
1050
  },
1026
1051
 
1027
1052
  async queue(
1028
- batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
1053
+ batch:
1054
+ | MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>
1055
+ | EdgeQueueBatch,
1029
1056
  bindings: WorkerBindings,
1030
1057
  ): Promise<void> {
1058
+ const lane = resolveRuntimeLane(bindings.YURUCOMMU_RUNTIME_LANE);
1031
1059
  return handleYurucommuQueueBatch(
1032
- wrapCloudflareMessageBatch(batch),
1033
- wrapCloudflareBindings(bindings),
1060
+ wrapRuntimeMessageBatch(batch, lane),
1061
+ wrapRuntimeBindings(bindings),
1034
1062
  );
1035
1063
  },
1036
1064
 
@@ -1041,7 +1069,7 @@ export default {
1041
1069
  ): Promise<void> {
1042
1070
  const env = isMaterializedRuntimeEnv(bindings)
1043
1071
  ? bindings
1044
- : wrapCloudflareBindings(bindings);
1072
+ : wrapRuntimeBindings(bindings);
1045
1073
  await runYurucommuRetention(env);
1046
1074
  },
1047
1075
  };
@@ -24,7 +24,7 @@ import {
24
24
  storyVotes,
25
25
  } from "../../db/index.ts";
26
26
  import type { Database } from "../../db/index.ts";
27
- import type { IObjectStorage } from "../runtime/types.ts";
27
+ import type { ObjectStore } from "../runtime/types.ts";
28
28
  import { chunkForInClause, D1_IN_CHUNK } from "./chunk.ts";
29
29
  import { normalizeDomain } from "./blocklist.ts";
30
30
  import { isSameActivityPubActor } from "./activitypub-actor-identity.ts";
@@ -112,7 +112,7 @@ function activityPubUrlHostMatchesDomain(
112
112
  async function purgeObjects(
113
113
  db: Database,
114
114
  apIds: string[],
115
- media?: IObjectStorage,
115
+ media?: ObjectStore,
116
116
  ): Promise<void> {
117
117
  if (apIds.length === 0) return;
118
118
  const parentRows = await db
@@ -544,7 +544,7 @@ async function purgeActorInteractionEdges(
544
544
  async function purgeMatchingObjects(
545
545
  db: Database,
546
546
  where: SQL,
547
- media?: IObjectStorage,
547
+ media?: ObjectStore,
548
548
  onPageDeleted?: (count: number) => void,
549
549
  ): Promise<void> {
550
550
  let cursor: string | undefined;
@@ -604,7 +604,7 @@ async function purgeMatchingActivities(
604
604
  async function purgeActorObjects(
605
605
  db: Database,
606
606
  blockedApId: string,
607
- media?: IObjectStorage,
607
+ media?: ObjectStore,
608
608
  onPageDeleted?: (count: number) => void,
609
609
  ): Promise<void> {
610
610
  let cursor: string | undefined;
@@ -666,7 +666,7 @@ async function purgeActorActivities(
666
666
  export async function purgeActorContent(
667
667
  db: Database,
668
668
  blockedApId: string,
669
- media?: IObjectStorage,
669
+ media?: ObjectStore,
670
670
  ): Promise<BlocklistContentPurgeResult> {
671
671
  let deletedObjects = 0;
672
672
  let deletedActivities = 0;
@@ -704,7 +704,7 @@ export async function purgeActorContent(
704
704
  export async function purgeDomainContent(
705
705
  db: Database,
706
706
  domainOrUrl: string,
707
- media?: IObjectStorage,
707
+ media?: ObjectStore,
708
708
  ): Promise<BlocklistContentPurgeResult> {
709
709
  const domain = normalizeDomain(domainOrUrl);
710
710
  if (!domain) {
@@ -36,13 +36,78 @@ export {
36
36
  createManagedRelationalDatabase,
37
37
  type ManagedRelationalDatabaseOptions,
38
38
  } from "./runtime/managed-relational.ts";
39
+ // The portable lane: the binding facades a wrapper host projects, and the lane
40
+ // selector that proves a deployment's declared lane against the bindings that
41
+ // actually arrived.
42
+ export {
43
+ DEFAULT_RUNTIME_LANE,
44
+ RUNTIME_LANE_VAR,
45
+ RUNTIME_LANES,
46
+ RuntimeLaneError,
47
+ assertRuntimeLaneBindings,
48
+ resolveRuntimeLane,
49
+ type CloudflareWorkerBindings,
50
+ type PortableWorkerBindings,
51
+ type RuntimeLane,
52
+ wrapPortableBindings,
53
+ wrapRuntimeBindings,
54
+ wrapRuntimeMessageBatch,
55
+ } from "./runtime/lane.ts";
56
+ export {
57
+ EDGE_KV_MAX_EXPIRATION_TTL_SECONDS,
58
+ EDGE_KV_MIN_EXPIRATION_TTL_SECONDS,
59
+ isEdgeObjectsBinding,
60
+ isEdgeQueueBatch,
61
+ isEdgeSqlBinding,
62
+ isNativeD1Database,
63
+ isNativeR2Bucket,
64
+ type EdgeKvBinding,
65
+ type EdgeObjectsBinding,
66
+ type EdgeQueueBatch,
67
+ type EdgeQueueBinding,
68
+ type EdgeSqlBinding,
69
+ type EdgeSqlResult,
70
+ type EdgeSqlValue,
71
+ } from "./runtime/edge-facades.ts";
72
+ export {
73
+ EdgeKeyValueOptionError,
74
+ EdgeKeyValueStore,
75
+ EdgeKeyValueValueError,
76
+ wrapEdgeKv,
77
+ } from "./runtime/edge-kv.ts";
78
+ export {
79
+ EdgeSqlShapeError,
80
+ createEdgeSqlDatabase,
81
+ } from "./runtime/edge-sql.ts";
82
+ export {
83
+ ProxyColumnMismatchError,
84
+ positionalRow,
85
+ rewriteProjection,
86
+ type ProjectedStatement,
87
+ type RewrittenStatement,
88
+ } from "./runtime/sqlite-proxy-rows.ts";
89
+ export {
90
+ EdgeQueueShapeError,
91
+ wrapEdgeMessageBatch,
92
+ wrapEdgeQueue,
93
+ } from "./runtime/edge-queue.ts";
94
+ export {
95
+ EdgeObjectStorage,
96
+ EdgeObjectsShapeError,
97
+ wrapEdgeObjects,
98
+ } from "./runtime/edge-objects.ts";
39
99
  export type {
40
100
  IKeyValueStore,
41
- IObjectStorage,
42
- ListObjectsResult,
43
- ObjectMetadata,
44
- StorageObject,
101
+ ObjectStore,
102
+ ObjectStoreBody,
103
+ ObjectStoreObject,
104
+ ObjectStorePutOptions,
45
105
  } from "./runtime/types.ts";
106
+ export {
107
+ createS3FetchObjectStore,
108
+ S3FetchObjectStoreError,
109
+ type S3ObjectFetcher,
110
+ } from "./runtime/s3-fetch.ts";
46
111
  export type {
47
112
  IQueueBatch,
48
113
  IQueueMessage,
@@ -47,7 +47,7 @@ import {
47
47
  } from "../../db/index.ts";
48
48
  import type { Database } from "../../db/index.ts";
49
49
  import type { Env } from "../types.ts";
50
- import type { IObjectStorage } from "../runtime/types.ts";
50
+ import type { ObjectStore } from "../runtime/types.ts";
51
51
  import { activityApId, generateId } from "../federation-helpers.ts";
52
52
  import { chunkForInClause } from "../lib/chunk.ts";
53
53
  import { snapshotAndEnqueueFollowerDeliveries } from "../lib/delivery/queue-batching.ts";
@@ -80,7 +80,7 @@ interface BatchableDb {
80
80
  */
81
81
  export async function purgeActorMediaUploads(
82
82
  db: Database,
83
- media: IObjectStorage | undefined,
83
+ media: ObjectStore | undefined,
84
84
  apId: string,
85
85
  ): Promise<void> {
86
86
  const uploads = await db
@@ -230,7 +230,7 @@ appsApiRoutes.post(
230
230
  }
231
231
  const r2Key = `${appPrefix}${normalizedPath}`;
232
232
  await media.put(r2Key, contentBytes, {
233
- httpMetadata: { contentType },
233
+ contentType,
234
234
  });
235
235
 
236
236
  results.push({ path: normalizedPath, status: "uploaded" });
@@ -276,14 +276,13 @@ appsServeRoutes.get("/:clientId/:appName/*", async (c) => {
276
276
 
277
277
  const object = await media.get(r2Key);
278
278
  if (object) {
279
- const contentType =
280
- object.httpMetadata?.contentType ?? inferContentType(filePath);
279
+ const contentType = object.contentType ?? inferContentType(filePath);
281
280
  const headers = createHostedHeaders(
282
281
  contentType,
283
282
  filePath.includes("/assets/")
284
283
  ? "public, max-age=31536000, immutable"
285
284
  : "public, max-age=3600",
286
- object.httpEtag,
285
+ object.etag,
287
286
  );
288
287
  return new Response(object.body, { headers });
289
288
  }
@@ -296,7 +295,7 @@ appsServeRoutes.get("/:clientId/:appName/*", async (c) => {
296
295
  const headers = createHostedHeaders(
297
296
  "text/html; charset=utf-8",
298
297
  "no-cache",
299
- indexObject.httpEtag,
298
+ indexObject.etag,
300
299
  );
301
300
  return new Response(indexObject.body, { headers });
302
301
  }
@@ -234,8 +234,12 @@ media.post("/upload", async (c) => {
234
234
  // transcode pipeline, and buffering a 40MB video would pressure the Worker
235
235
  // memory budget.
236
236
  if (isVideo) {
237
+ // The Blob is handed over whole rather than as a bare stream: an
238
+ // ObjectStore adapter reads `File.size` from it, so the portable
239
+ // `edge.objects` lane can declare the length while streaming instead of
240
+ // buffering the whole video in the Worker to discover it.
237
241
  await media.put(r2Key, file, {
238
- httpMetadata: { contentType },
242
+ contentType,
239
243
  });
240
244
  } else {
241
245
  const original = new Uint8Array(await file.arrayBuffer());
@@ -247,7 +251,7 @@ media.post("/upload", async (c) => {
247
251
  cleaned.byteOffset + cleaned.byteLength,
248
252
  ) as ArrayBuffer;
249
253
  await media.put(r2Key, cleanedBuffer, {
250
- httpMetadata: { contentType },
254
+ contentType,
251
255
  });
252
256
  }
253
257
 
@@ -579,13 +583,12 @@ async function serveMediaByR2Key(c: MediaContext, r2Key: string) {
579
583
  const object = await media.get(r2Key);
580
584
  if (!object) return c.notFound();
581
585
 
582
- const contentType =
583
- object.httpMetadata?.contentType || "application/octet-stream";
586
+ const contentType = object.contentType || "application/octet-stream";
584
587
  const cacheScope = authResult.isPublic ? "public" : "private";
585
588
  const maxAge = contentType.startsWith("video/")
586
589
  ? CACHE_MAX_AGE_VIDEO
587
590
  : CACHE_MAX_AGE_IMAGE;
588
- const etag = object.httpEtag;
591
+ const etag = object.etag;
589
592
 
590
593
  if (!object.body) {
591
594
  return c.body(null, 200, {
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
23
23
  import type { D1Statement, Database } from "../../../db/index.ts";
24
- import type { IObjectStorage } from "../../runtime/types.ts";
24
+ import type { ObjectStore } from "../../runtime/types.ts";
25
25
  import {
26
26
  activities,
27
27
  actors,
@@ -80,7 +80,7 @@ async function deleteAttachedMediaUploadsForObject(
80
80
  db: Database,
81
81
  obj: CascadeObject,
82
82
  removedObjectApIds: ReadonlySet<string>,
83
- media?: IObjectStorage,
83
+ media?: ObjectStore,
84
84
  ): Promise<{
85
85
  mediaKeys: string[];
86
86
  mediaUploadIds: string[];
@@ -191,7 +191,7 @@ async function deleteAttachedMediaUploadsForObject(
191
191
  * system's already-accepted media failure mode.
192
192
  */
193
193
  export async function purgeMediaBlobs(
194
- media: IObjectStorage | undefined,
194
+ media: ObjectStore | undefined,
195
195
  keys: string[],
196
196
  ): Promise<void> {
197
197
  if (!media || keys.length === 0) return;
@@ -220,7 +220,7 @@ export async function reapReplacedMediaUrl(
220
220
  db: Database,
221
221
  oldUrl: string | null | undefined,
222
222
  uploaderApId: string,
223
- media?: IObjectStorage,
223
+ media?: ObjectStore,
224
224
  ): Promise<void> {
225
225
  try {
226
226
  if (!oldUrl || !oldUrl.startsWith("/media/")) return;
@@ -303,7 +303,7 @@ export async function reapReplacedMediaUrl(
303
303
  export async function deleteObjectCascade(
304
304
  db: Database,
305
305
  objectApId: string,
306
- media?: IObjectStorage,
306
+ media?: ObjectStore,
307
307
  ): Promise<string[]> {
308
308
  return await deleteObjectsCascade(db, [objectApId], media);
309
309
  }
@@ -316,7 +316,7 @@ export async function deleteObjectCascade(
316
316
  export async function prepareObjectDeleteCascade(
317
317
  db: Database,
318
318
  objectApId: string,
319
- media?: IObjectStorage,
319
+ media?: ObjectStore,
320
320
  ): Promise<{
321
321
  mediaKeys: string[];
322
322
  statements: readonly [D1Statement, ...D1Statement[]];
@@ -392,7 +392,7 @@ export async function prepareObjectDeleteCascade(
392
392
  export async function deleteObjectsCascade(
393
393
  db: Database,
394
394
  objectApIds: string[],
395
- media?: IObjectStorage,
395
+ media?: ObjectStore,
396
396
  ): Promise<string[]> {
397
397
  const uniqueApIds = [...new Set(objectApIds)];
398
398
  if (uniqueApIds.length === 0) return [];
@@ -8,7 +8,7 @@ import {
8
8
  objects,
9
9
  storyVotes,
10
10
  } from "../../../db/index.ts";
11
- import type { IObjectStorage } from "../../runtime/types.ts";
11
+ import type { ObjectStore } from "../../runtime/types.ts";
12
12
  import {
13
13
  formatUsername,
14
14
  objectApId,
@@ -314,7 +314,7 @@ export function buildAuthor(
314
314
 
315
315
  export async function cleanupExpiredStories(
316
316
  db: Database,
317
- media?: IObjectStorage,
317
+ media?: ObjectStore,
318
318
  ): Promise<number> {
319
319
  const now = new Date().toISOString();
320
320
 
@@ -18,7 +18,7 @@ import {
18
18
  purgeMediaBlobs,
19
19
  } from "../posts/delete-cascade.ts";
20
20
  import type { Env, Variables } from "../../types.ts";
21
- import type { IObjectStorage } from "../../runtime/types.ts";
21
+ import type { ObjectStore } from "../../runtime/types.ts";
22
22
  import {
23
23
  activityApId,
24
24
  actorApId,
@@ -61,10 +61,7 @@ const stories = new Hono<{ Bindings: Env; Variables: Variables }>();
61
61
  // at most one fallback pass runs at a time per isolate.
62
62
  let expiredStoryCleanupInFlight = false;
63
63
 
64
- function maybeCleanupExpiredStories(
65
- db: Database,
66
- media?: IObjectStorage,
67
- ): void {
64
+ function maybeCleanupExpiredStories(db: Database, media?: ObjectStore): void {
68
65
  if (expiredStoryCleanupInFlight) return;
69
66
  if (Math.random() >= 0.01) return; // ~1% of feed requests per isolate
70
67