@takosjp/yurucommu-core 4.0.0 → 4.1.1

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": "4.0.0",
3
+ "version": "4.1.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -59,8 +59,9 @@
59
59
  "start": "bun src/backend/server.ts",
60
60
  "dev": "bun src/backend/server.ts",
61
61
  "dev:server": "bun src/backend/server.ts",
62
- "check": "bun run fmt:check && tsc --noEmit && bun run check:no-opentofu-artifacts && bun run test",
62
+ "check": "bun run fmt:check && tsc --noEmit && bun run check:no-opentofu-artifacts && bun run check:worker-bundle && bun run test",
63
63
  "check:no-opentofu-artifacts": "bun scripts/check-no-opentofu-artifacts.mjs",
64
+ "check:worker-bundle": "bun scripts/check-worker-bundle-portable.mjs",
64
65
  "test": "bun run build:api && bun test test/ src/backend/ packages/api/src/ scripts/check-publish-version-discipline.test.ts scripts/publish-package-resumable.test.ts && bun run check:release-contents",
65
66
  "test:backend": "bun test src/backend/",
66
67
  "build": "bun run build:api",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "4.0.0",
3
+ "version": "4.1.1",
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
  };
@@ -36,6 +36,66 @@ 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
101
  ObjectStore,
@@ -234,6 +234,10 @@ 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
242
  contentType,
239
243
  });
@@ -24,7 +24,7 @@ import {
24
24
  assertPathChainWithinBasePath,
25
25
  isPathWithinBasePath,
26
26
  resolvePathWithinBasePath,
27
- } from "./shared.ts";
27
+ } from "./node-paths.ts";
28
28
  import { MemoryKV } from "./memory-kv.ts";
29
29
  import { isBackendPath } from "../lib/backend-paths.ts";
30
30
  import { loadBunSqlite } from "./compat-bun/types.ts";
@@ -0,0 +1,353 @@
1
+ /**
2
+ * The portable binding facades a Takoserver-hosted Worker receives.
3
+ *
4
+ * A Worker Version published through Takoform onto a Takoserver Host does not
5
+ * get Cloudflare's native `KVNamespace` / `D1Database` / `Queue` / `R2Bucket`
6
+ * objects. The Host's generated entrypoint replaces `env` with an object whose
7
+ * bindings are the exact facades named by the Interface the Version declared —
8
+ * `edge.kv@1.0.0`, `edge.sql@1.0.0`, `edge.queue@1.0.0`, `edge.objects@1.0.0`.
9
+ * The managed Cloudflare backend and the self-host backend project the SAME
10
+ * facade: same methods, same option keys, same error names. Takoserver's
11
+ * ADR 0005 states this explicitly for object storage, and its self-host wrapper
12
+ * repeats it for all four.
13
+ *
14
+ * This module is a TYPE MIRROR of that contract plus the structural probes the
15
+ * lane selector uses. It deliberately contains no behaviour: the adapters that
16
+ * map a facade onto this repo's runtime ports live in `edge-kv.ts`,
17
+ * `edge-sql.ts`, `edge-queue.ts`, and `edge-objects.ts`.
18
+ *
19
+ * Source of truth (read, do not re-derive from memory):
20
+ * takoserver `src/providers/cloudflare-managed-worker-wrapper.ts`
21
+ * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`,
22
+ * `createQueueAdapter`, `createEdgeObjectsR2Adapter`
23
+ * takoserver `src/providers/selfhost-worker-wrapper.ts`
24
+ * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`,
25
+ * `createQueueAdapter`, `createObjectsAdapter`
26
+ *
27
+ * Every method rejects with an `Error` whose `name` is the portable error code
28
+ * (`invalid_key`, `invalid_value`, `value_too_large`, `metadata_too_large`,
29
+ * `invalid_cursor`, `invalid_argument`, `sql_error`, `numeric_out_of_range`,
30
+ * `busy`, `not_found`, `precondition_failed`, `range_not_satisfiable`,
31
+ * `invalid_body`, `message_too_large`, `batch_too_large`, `invalid_part`,
32
+ * `already_settled`, `backend_unavailable`). The adapters let those propagate
33
+ * unchanged so a caller sees the Host's own vocabulary.
34
+ */
35
+
36
+ /** Limits the facades enforce. Mirrored so the adapters can fail before the
37
+ * round-trip instead of surfacing an opaque `invalid_*` from the Host. */
38
+ export const EDGE_KV_MAX_KEY_BYTES = 467;
39
+ export const EDGE_KV_MAX_VALUE_BYTES = 26214400;
40
+ /** `expirationTtlSeconds` is rejected outside this range by both backends. */
41
+ export const EDGE_KV_MIN_EXPIRATION_TTL_SECONDS = 60;
42
+ export const EDGE_KV_MAX_EXPIRATION_TTL_SECONDS = 315360000;
43
+ export const EDGE_KV_MAX_LIST_LIMIT = 1000;
44
+ export const EDGE_SQL_MAX_STATEMENTS = 100;
45
+ export const EDGE_SQL_MAX_PARAMETERS = 100;
46
+ export const EDGE_SQL_MAX_ROWS = 10000;
47
+ export const EDGE_SQL_MAX_COLUMNS = 100;
48
+ export const EDGE_QUEUE_MAX_MESSAGES = 100;
49
+
50
+ /** A byte string on the wire. The facades never hand out raw `Uint8Array`. */
51
+ export interface EdgeEncodedBytes {
52
+ readonly encoding: "base64";
53
+ readonly data: string;
54
+ }
55
+
56
+ /** Exactly what `edge.sql` accepts as a bound parameter and returns in a row. */
57
+ export type EdgeSqlValue = null | number | string | EdgeEncodedBytes;
58
+
59
+ export interface EdgeSqlStatement {
60
+ readonly sql: string;
61
+ readonly params?: readonly EdgeSqlValue[];
62
+ }
63
+
64
+ /**
65
+ * One statement's result. `rows` are RECORDS keyed by result-column name, not
66
+ * positional arrays — the single most consequential difference from D1, and the
67
+ * reason the lane has to rewrite the projection list (`sqlite-proxy-rows.ts`)
68
+ * before it can hand anything to Drizzle.
69
+ */
70
+ export interface EdgeSqlResult {
71
+ readonly rows: readonly Readonly<Record<string, EdgeSqlValue>>[];
72
+ readonly rowsWritten: number;
73
+ }
74
+
75
+ /** `edge.sql@1.0.0`. */
76
+ export interface EdgeSqlBinding {
77
+ execute(
78
+ sql: string,
79
+ params?: readonly EdgeSqlValue[],
80
+ ): Promise<EdgeSqlResult>;
81
+ /** `execute` restricted to statements that write nothing. */
82
+ query(sql: string, params?: readonly EdgeSqlValue[]): Promise<EdgeSqlResult>;
83
+ /** All-or-none. 1..100 statements, ordered, one Host round trip. */
84
+ transaction(
85
+ statements: readonly EdgeSqlStatement[],
86
+ ): Promise<readonly EdgeSqlResult[]>;
87
+ }
88
+
89
+ export interface EdgeKvPutOptions {
90
+ readonly expirationTtlSeconds?: number;
91
+ /** String values only; the Host projects a record of strings. */
92
+ readonly metadata?: Record<string, string>;
93
+ }
94
+
95
+ export interface EdgeKvListOptions {
96
+ readonly prefix?: string;
97
+ readonly cursor?: string;
98
+ readonly limit?: number;
99
+ }
100
+
101
+ /**
102
+ * A listed key carries its NAME ONLY. Neither backend returns the expiration or
103
+ * the metadata it stored, so `IKeyValueStore.list` reports those as absent on
104
+ * this lane.
105
+ */
106
+ export interface EdgeKvListResult {
107
+ readonly keys: readonly { readonly name: string }[];
108
+ readonly listComplete: boolean;
109
+ readonly cursor?: string;
110
+ }
111
+
112
+ /** `edge.kv@1.0.0`. Values are always bytes; there is no `type` option. */
113
+ export interface EdgeKvBinding {
114
+ get(key: string): Promise<ArrayBuffer | null>;
115
+ getWithMetadata(key: string): Promise<{
116
+ readonly value: ArrayBuffer;
117
+ readonly metadata?: Record<string, string>;
118
+ } | null>;
119
+ put(
120
+ key: string,
121
+ value: string | ArrayBuffer | ArrayBufferView,
122
+ options?: EdgeKvPutOptions,
123
+ ): Promise<void>;
124
+ delete(key: string): Promise<void>;
125
+ list(options?: EdgeKvListOptions): Promise<EdgeKvListResult>;
126
+ }
127
+
128
+ export interface EdgeQueueSendOptions {
129
+ readonly delaySeconds?: number;
130
+ }
131
+
132
+ export interface EdgeQueueBatchItem {
133
+ readonly body: string | ArrayBuffer | ArrayBufferView;
134
+ readonly delaySeconds?: number;
135
+ }
136
+
137
+ /**
138
+ * `edge.queue@1.0.0` producer. Bodies are BYTES — there is no structured-clone
139
+ * path, so a JavaScript object has to be serialized by the caller. `send`
140
+ * returns the Host's acceptance id, which is not a provider dedupe id.
141
+ */
142
+ export interface EdgeQueueBinding {
143
+ send(
144
+ body: string | ArrayBuffer | ArrayBufferView,
145
+ options?: EdgeQueueSendOptions,
146
+ ): Promise<string>;
147
+ sendBatch(
148
+ messages: readonly EdgeQueueBatchItem[],
149
+ ): Promise<readonly string[]>;
150
+ }
151
+
152
+ /** One message as the Host hands it to a declared `queue` handler. */
153
+ export interface EdgeQueueMessage {
154
+ readonly id: string;
155
+ readonly timestampMillis: number;
156
+ readonly attempts: number;
157
+ readonly body: EdgeEncodedBytes;
158
+ acknowledge(): void;
159
+ /** `delaySeconds`, when given, must be >= 1. */
160
+ retry(options?: { readonly delaySeconds?: number }): void;
161
+ }
162
+
163
+ export interface EdgeQueueBatch {
164
+ readonly batchId: string;
165
+ readonly queue: string;
166
+ readonly messages: readonly EdgeQueueMessage[];
167
+ acknowledgeAll(): void;
168
+ retryAll(options?: { readonly delaySeconds?: number }): void;
169
+ }
170
+
171
+ export interface EdgeObjectMetadata {
172
+ readonly etag: string;
173
+ readonly size: number;
174
+ readonly contentType?: string;
175
+ readonly uploadedAtMillis?: number;
176
+ }
177
+
178
+ export interface EdgeObjectBody extends EdgeObjectMetadata {
179
+ readonly body: ReadableStream;
180
+ readonly partial: boolean;
181
+ readonly range?: { readonly offset: number; readonly length: number };
182
+ }
183
+
184
+ export interface EdgeObjectListResult {
185
+ readonly objects: readonly (EdgeObjectMetadata & { readonly key: string })[];
186
+ readonly prefixes: readonly string[];
187
+ readonly truncated: boolean;
188
+ readonly cursor?: string;
189
+ }
190
+
191
+ /**
192
+ * `edge.objects@1.0.0`. Note the fixed arities — the Host counts
193
+ * `arguments.length`, so `get(key)` with one argument is a type error and the
194
+ * adapter must pass `undefined` explicitly. There is no `customMetadata`, and a
195
+ * streaming `put` requires `contentLength`.
196
+ */
197
+ export interface EdgeObjectsBinding {
198
+ head(key: string): Promise<EdgeObjectMetadata | null>;
199
+ get(
200
+ key: string,
201
+ options:
202
+ undefined | { readonly range?: { offset: number; length?: number } },
203
+ ): Promise<EdgeObjectBody | null>;
204
+ put(
205
+ key: string,
206
+ body: string | ArrayBuffer | ArrayBufferView | ReadableStream,
207
+ options:
208
+ | undefined
209
+ | {
210
+ readonly contentLength?: number;
211
+ readonly contentType?: string;
212
+ },
213
+ ): Promise<{ readonly etag: string; readonly size: number }>;
214
+ delete(key: string): Promise<void>;
215
+ list(
216
+ options:
217
+ | undefined
218
+ | {
219
+ readonly prefix?: string;
220
+ readonly delimiter?: string;
221
+ readonly cursor?: string;
222
+ readonly limit?: number;
223
+ },
224
+ ): Promise<EdgeObjectListResult>;
225
+ }
226
+
227
+ function hasMethods(value: unknown, names: readonly string[]): boolean {
228
+ if (typeof value !== "object" || value === null) return false;
229
+ const record = value as Record<string, unknown>;
230
+ for (const name of names) {
231
+ if (typeof record[name] !== "function") return false;
232
+ }
233
+ return true;
234
+ }
235
+
236
+ /**
237
+ * Structural probes.
238
+ *
239
+ * Only ONE binding can be told apart by shape, and the difference matters:
240
+ *
241
+ * decisive `DB` — `execute`/`query`/`transaction` (facade) against
242
+ * `prepare`/`batch` (D1). Disjoint method sets.
243
+ * decisive a queue *batch* — `acknowledgeAll` (facade) against `ackAll`
244
+ * (Cloudflare `MessageBatch`).
245
+ * AMBIGUOUS `KV` — `edge.kv` and `KVNamespace` expose the same five
246
+ * method names.
247
+ * AMBIGUOUS a queue *producer* — both are `send`/`sendBatch`.
248
+ * AMBIGUOUS `MEDIA` — `edge.objects@1.0.0` is R2's method set by design,
249
+ * multipart helpers included. See below.
250
+ *
251
+ * That is why the lane is a DECLARED variable rather than something sniffed:
252
+ * three of the five bindings cannot be identified at all. The declaration is
253
+ * then cross-checked against the decisive bindings, so a Worker whose var and
254
+ * whose bindings disagree refuses to start instead of calling
255
+ * `kv.get(key, {type})` on a facade that would silently treat the options
256
+ * object as nothing.
257
+ */
258
+ export function isEdgeSqlBinding(value: unknown): value is EdgeSqlBinding {
259
+ return (
260
+ hasMethods(value, ["execute", "query", "transaction"]) &&
261
+ typeof (value as Record<string, unknown>).prepare !== "function"
262
+ );
263
+ }
264
+
265
+ /** Cloudflare's `D1Database` is the `prepare`/`batch`/`exec` shape. */
266
+ export function isNativeD1Database(value: unknown): boolean {
267
+ return (
268
+ hasMethods(value, ["prepare", "batch"]) &&
269
+ typeof (value as Record<string, unknown>).execute !== "function"
270
+ );
271
+ }
272
+
273
+ export function isEdgeQueueBatch(value: unknown): value is EdgeQueueBatch {
274
+ return (
275
+ hasMethods(value, ["acknowledgeAll", "retryAll"]) &&
276
+ Array.isArray((value as Record<string, unknown>).messages)
277
+ );
278
+ }
279
+
280
+ /**
281
+ * A bucket-shaped binding with `edge.objects@1.0.0`'s call signatures.
282
+ *
283
+ * NOT DECISIVE, and never a lane test. The facade is R2's method set on
284
+ * purpose — `head`, `get`, `put`, `delete`, `list`, and the four multipart
285
+ * calls — so that an app written against R2 ports over unchanged. A real
286
+ * `R2Bucket` therefore answers `true` here too. Use it to check that SOMETHING
287
+ * bucket-shaped arrived, never to decide which host projected it: that is what
288
+ * `YURUCOMMU_RUNTIME_LANE` is for.
289
+ *
290
+ * The arity check is the facade's own contract rather than a discriminator:
291
+ * the Host counts `arguments.length`, so `get` takes its options slot even when
292
+ * that slot is `undefined`.
293
+ */
294
+ export function isEdgeObjectsBinding(
295
+ value: unknown,
296
+ ): value is EdgeObjectsBinding {
297
+ return (
298
+ hasMethods(value, ["head", "get", "put", "delete", "list"]) &&
299
+ (value as { get: (...args: unknown[]) => unknown }).get.length === 2
300
+ );
301
+ }
302
+
303
+ /**
304
+ * Cloudflare's `R2Bucket` — and, unavoidably, the `edge.objects` facade.
305
+ *
306
+ * A method-name test cannot separate the two, because Takoserver's facade
307
+ * carries `createMultipartUpload` as well (`selfhost-worker-wrapper.ts`
308
+ * `createObjectsAdapter`). 4.1.0 used this function to refuse the portable
309
+ * lane and so refused every self-hosted deployment. Keep it for describing a
310
+ * binding; do not let it decide a lane.
311
+ */
312
+ export function isNativeR2Bucket(value: unknown): boolean {
313
+ return hasMethods(value, [
314
+ "head",
315
+ "get",
316
+ "put",
317
+ "delete",
318
+ "list",
319
+ "createMultipartUpload",
320
+ ]);
321
+ }
322
+
323
+ /** Decode one `{encoding:"base64"}` value into bytes. */
324
+ export function decodeEdgeBytes(value: EdgeEncodedBytes): Uint8Array {
325
+ const binary = atob(value.data);
326
+ const bytes = new Uint8Array(binary.length);
327
+ for (let index = 0; index < binary.length; index += 1) {
328
+ bytes[index] = binary.charCodeAt(index);
329
+ }
330
+ return bytes;
331
+ }
332
+
333
+ /** Encode bytes into the facade's wire value. */
334
+ export function encodeEdgeBytes(bytes: Uint8Array): EdgeEncodedBytes {
335
+ let binary = "";
336
+ // Chunked so a large blob does not blow the argument limit of `apply`.
337
+ const CHUNK = 0x8000;
338
+ for (let index = 0; index < bytes.length; index += CHUNK) {
339
+ binary += String.fromCharCode(
340
+ ...bytes.subarray(index, Math.min(index + CHUNK, bytes.length)),
341
+ );
342
+ }
343
+ return { encoding: "base64", data: btoa(binary) };
344
+ }
345
+
346
+ export function isEdgeEncodedBytes(value: unknown): value is EdgeEncodedBytes {
347
+ return (
348
+ typeof value === "object" &&
349
+ value !== null &&
350
+ (value as EdgeEncodedBytes).encoding === "base64" &&
351
+ typeof (value as EdgeEncodedBytes).data === "string"
352
+ );
353
+ }