@takosjp/yurucommu-core 4.1.3 → 4.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "4.1.3",
3
+ "version": "4.1.4",
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": "4.1.3",
3
+ "version": "4.1.4",
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",
@@ -108,8 +108,17 @@ export {
108
108
  } from "./runtime/edge-queue.ts";
109
109
  export {
110
110
  EdgeObjectStorage,
111
+ EdgeObjectsBucket,
111
112
  EdgeObjectsShapeError,
113
+ type EdgeObjectHttpMetadata,
114
+ type EdgeObjectRange,
115
+ type EdgeObjectsGetOptions,
116
+ type EdgeObjectsListOptions,
117
+ type EdgeR2Object,
118
+ type EdgeR2ObjectBody,
119
+ type EdgeR2Objects,
112
120
  wrapEdgeObjects,
121
+ wrapEdgeObjectsAsBucket,
113
122
  } from "./runtime/edge-objects.ts";
114
123
  export type {
115
124
  IKeyValueStore,
@@ -1,14 +1,60 @@
1
1
  /**
2
- * `edge.objects@1.0.0` → {@link ObjectStore}.
2
+ * `edge.objects@1.0.0` → an R2-shaped bucket, and → {@link ObjectStore}.
3
3
  *
4
- * The facade is deliberately narrower than R2, and the narrow spots are the
5
- * interesting ones:
4
+ * ## What the Host hands over, and what R2 hands over
5
+ *
6
+ * Takoserver's facade is method-for-method a bucket (`head`, `get`, `put`,
7
+ * `delete`, `list`, plus the four multipart calls), which is the whole point of
8
+ * the Interface: an app written against R2 is supposed to port over unchanged.
9
+ * The RESULT objects were not: the facade's `get()` answers with a plain record
10
+ * of `{etag, size, contentType?, body, partial, range?}`, while a native
11
+ * `R2ObjectBody` also carries `text()`, `json()`, `arrayBuffer()`, `blob()`,
12
+ * `key`, `httpEtag`, `uploaded`, `httpMetadata` and `writeHttpMetadata()`. So
13
+ * the facade WAS distinguishable from R2 — by exactly the members an app is
14
+ * most likely to reach for. `await (await env.MEDIA.get(k)).text()`, which is
15
+ * legal R2, threw `o.text is not a function` on the portable lane.
16
+ *
17
+ * The wire contract is Takoserver's (ADR 0005) and does not move. This module
18
+ * closes the gap on THIS side: {@link EdgeObjectsBucket} wraps the binding and
19
+ * returns objects that carry R2's members, so R2-shaped app code compiles and
20
+ * runs against either host.
21
+ *
22
+ * ## The parity rule
23
+ *
24
+ * PROVIDED, with R2's names and R2's semantics: `key`, `size`, `etag`,
25
+ * `httpEtag`, `httpMetadata`, `customMetadata`, `range`, `writeHttpMetadata()`,
26
+ * and on a body answer `body`, `bodyUsed`, `arrayBuffer()`, `text()`, `json()`,
27
+ * `blob()`. The four body helpers and `bodyUsed` are a real `Response`'s, so a
28
+ * second read REJECTS with a `TypeError` exactly as R2's do rather than
29
+ * replaying a cached value, and reading `body` directly also marks the object
30
+ * used. `blob()` answers with the bytes and no `type`; the stored content type
31
+ * is read from `httpMetadata` / `writeHttpMetadata()`, which is where R2 keeps
32
+ * it too.
33
+ *
34
+ * BEST EFFORT: `uploaded`. The Host's `head` and `list` carry
35
+ * `uploadedAtMillis`, so it is a `Date` there. Its `get` and `put` do NOT —
36
+ * both Takoserver wrapper backends build their answer without it on purpose —
37
+ * so it is `undefined` there rather than invented. It is `Date | undefined`
38
+ * everywhere so one type describes all four.
39
+ *
40
+ * NOT PROVIDED, because the wire carries nothing to derive them from:
41
+ * `version`, `checksums`, `storageClass`. `customMetadata` is present and
42
+ * always `undefined`: ADR 0005 gives `edge.objects` no custom metadata at all,
43
+ * so "absent" is the true answer rather than a missing member.
44
+ *
45
+ * `etag` is the Host's etag VERBATIM and is opaque: the self-host wrapper sends
46
+ * a bare hex digest (R2's unquoted `etag` spelling) and the managed wrapper
47
+ * forwards R2's quoted `httpEtag`. This facade does not rewrite it, because
48
+ * that value is what every conditional request on either host must echo back.
49
+ * `httpEtag` is derived: the same value, quoted when it was not already, which
50
+ * is the header-safe spelling R2 guarantees.
51
+ *
52
+ * ## The narrow spots of the facade itself, which parity does not widen
6
53
  *
7
54
  * - NO CUSTOM METADATA. Only `contentType` survives a round trip, which is
8
55
  * also all the provider-neutral {@link ObjectStorePutOptions} carries.
9
- * - FIXED ARITIES. The Host counts `arguments.length`, so `get` and `put` are
10
- * always called with their full argument list even when the options are
11
- * absent.
56
+ * - FIXED ARITIES. The Host counts `arguments.length`, so every call passes
57
+ * its full argument list even when the options slot is absent.
12
58
  * - A STREAMING `put` NEEDS `contentLength`. ADR 0005 is explicit that a Host
13
59
  * enforces the declared count while streaming and never buffers a body to
14
60
  * discover its size. Every body shape but a bare `ReadableStream` already
@@ -16,10 +62,12 @@
16
62
  * `ArrayBuffer`, a string — so the length is declared and the bytes stream
17
63
  * through. A stream that arrives without a knowable length is buffered
18
64
  * HERE, in the Worker, which is the honest cost of not knowing the size.
19
- * - `delete` TAKES ONE KEY. The port's array form becomes a sequence of calls,
20
- * which is not atomic — the same as R2's, which also has no transaction.
21
- * - NO ENUMERATION OR HEAD. The port does not carry them, so neither does the
22
- * adapter, even though the Host projects both.
65
+ * - `delete` TAKES ONE KEY. The bucket's array form becomes a sequence of
66
+ * calls, which is not atomic — the same as R2's, which also has no
67
+ * transaction.
68
+ * - AN UNRANGED `get` MUST NOT BE PARTIAL. A truncated body served as a whole
69
+ * object is a silent corruption, so the bytes are dropped and the call
70
+ * throws.
23
71
  *
24
72
  * AVAILABILITY: BOTH wrapper backends project `edge.objects`. The managed
25
73
  * Cloudflare backend does it over provider-private R2
@@ -37,7 +85,11 @@ import type {
37
85
  ObjectStoreObject,
38
86
  ObjectStorePutOptions,
39
87
  } from "./types.ts";
40
- import type { EdgeObjectsBinding } from "./edge-facades.ts";
88
+ import type {
89
+ EdgeObjectBody,
90
+ EdgeObjectMetadata,
91
+ EdgeObjectsBinding,
92
+ } from "./edge-facades.ts";
41
93
  import { readStream } from "./shared.ts";
42
94
 
43
95
  /** A request or response the facade cannot express. */
@@ -48,6 +100,198 @@ export class EdgeObjectsShapeError extends TypeError {
48
100
  }
49
101
  }
50
102
 
103
+ /**
104
+ * R2's `R2HTTPMetadata`, restricted to the one field `edge.objects` carries.
105
+ *
106
+ * The other five R2 fields (`contentLanguage`, `contentDisposition`,
107
+ * `contentEncoding`, `cacheControl`, `cacheExpiry`) are absent on every answer
108
+ * because the Interface never accepted them on `put`.
109
+ */
110
+ export interface EdgeObjectHttpMetadata {
111
+ readonly contentType?: string;
112
+ }
113
+
114
+ /** A byte range the Host actually served, in R2's `R2Range` spelling. */
115
+ export interface EdgeObjectRange {
116
+ readonly offset: number;
117
+ readonly length: number;
118
+ }
119
+
120
+ /**
121
+ * R2's `R2Object` over `edge.objects@1.0.0`: what `head`, `put` and a `list`
122
+ * entry answer with.
123
+ */
124
+ export interface EdgeR2Object {
125
+ readonly key: string;
126
+ readonly size: number;
127
+ /** The Host's etag verbatim. Opaque; quoting differs by backend. */
128
+ readonly etag: string;
129
+ /** The same etag in R2's header-safe quoted spelling. */
130
+ readonly httpEtag: string;
131
+ /** A `Date` on `head` and `list`; `undefined` on `get` and `put`. */
132
+ readonly uploaded: Date | undefined;
133
+ readonly httpMetadata: EdgeObjectHttpMetadata;
134
+ /** Always `undefined`: `edge.objects` has no custom metadata (ADR 0005). */
135
+ readonly customMetadata: undefined;
136
+ /** Present only on a ranged `get`. */
137
+ readonly range?: EdgeObjectRange;
138
+ /** Writes the metadata this object carries onto response headers. */
139
+ writeHttpMetadata(headers: Headers): void;
140
+ }
141
+
142
+ /** R2's `R2ObjectBody`: an {@link EdgeR2Object} whose bytes came with it. */
143
+ export interface EdgeR2ObjectBody extends EdgeR2Object {
144
+ readonly body: ReadableStream<Uint8Array>;
145
+ readonly bodyUsed: boolean;
146
+ arrayBuffer(): Promise<ArrayBuffer>;
147
+ text(): Promise<string>;
148
+ json<T = unknown>(): Promise<T>;
149
+ blob(): Promise<Blob>;
150
+ }
151
+
152
+ /** R2's `R2Objects`: one page of {@link EdgeObjectsBucket.list}. */
153
+ export interface EdgeR2Objects {
154
+ readonly objects: readonly EdgeR2Object[];
155
+ readonly truncated: boolean;
156
+ readonly cursor?: string;
157
+ /** R2's name for the common prefixes a `delimiter` collapsed. */
158
+ readonly delimitedPrefixes: readonly string[];
159
+ }
160
+
161
+ export interface EdgeObjectsGetOptions {
162
+ readonly range?: { readonly offset: number; readonly length?: number };
163
+ }
164
+
165
+ export interface EdgeObjectsListOptions {
166
+ readonly prefix?: string;
167
+ readonly delimiter?: string;
168
+ readonly cursor?: string;
169
+ readonly limit?: number;
170
+ }
171
+
172
+ /** R2 quotes its `httpEtag`; the Host's etag may or may not already be quoted. */
173
+ function httpEtagOf(etag: string): string {
174
+ if (etag.length >= 2 && etag.startsWith('"') && etag.endsWith('"')) {
175
+ return etag;
176
+ }
177
+ return `"${etag}"`;
178
+ }
179
+
180
+ function writeContentType(
181
+ contentType: string | undefined,
182
+ headers: Headers,
183
+ ): void {
184
+ // R2 writes only the fields its `httpMetadata` actually holds, so an object
185
+ // stored without a content type leaves the caller's headers alone.
186
+ if (contentType !== undefined) headers.set("content-type", contentType);
187
+ }
188
+
189
+ /**
190
+ * R2's `R2Object`. Metadata only: `head`, `put` and every `list` entry.
191
+ */
192
+ class EdgeR2ObjectMetadata implements EdgeR2Object {
193
+ readonly key: string;
194
+ readonly size: number;
195
+ readonly etag: string;
196
+ readonly httpEtag: string;
197
+ readonly uploaded: Date | undefined;
198
+ readonly httpMetadata: EdgeObjectHttpMetadata;
199
+ readonly customMetadata: undefined = undefined;
200
+ readonly range?: EdgeObjectRange;
201
+
202
+ constructor(
203
+ key: string,
204
+ metadata: {
205
+ readonly etag: string;
206
+ readonly size: number;
207
+ readonly contentType?: string;
208
+ readonly uploadedAtMillis?: number;
209
+ },
210
+ range?: EdgeObjectRange,
211
+ ) {
212
+ this.key = key;
213
+ this.size = metadata.size;
214
+ this.etag = metadata.etag;
215
+ this.httpEtag = httpEtagOf(metadata.etag);
216
+ this.uploaded =
217
+ metadata.uploadedAtMillis === undefined
218
+ ? undefined
219
+ : new Date(metadata.uploadedAtMillis);
220
+ this.httpMetadata =
221
+ metadata.contentType === undefined
222
+ ? {}
223
+ : { contentType: metadata.contentType };
224
+ if (range !== undefined) this.range = range;
225
+ }
226
+
227
+ writeHttpMetadata(headers: Headers): void {
228
+ writeContentType(this.httpMetadata.contentType, headers);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * R2's `R2ObjectBody`.
234
+ *
235
+ * The bytes are held in a `Response`, which is where the body semantics come
236
+ * from rather than being re-implemented: `bodyUsed` flips the moment the stream
237
+ * is disturbed — including by a caller that read `body` itself — and a second
238
+ * `text()` / `json()` / `arrayBuffer()` / `blob()` REJECTS with a `TypeError`
239
+ * instead of replaying the first read. That is R2's own behaviour (workerd's
240
+ * `R2ObjectBody` refuses a disturbed body the same way the `Body` mixin does),
241
+ * so a caller cannot tell the two apart by consuming twice.
242
+ */
243
+ class EdgeR2ObjectWithBody
244
+ extends EdgeR2ObjectMetadata
245
+ implements EdgeR2ObjectBody
246
+ {
247
+ readonly #response: Response;
248
+
249
+ constructor(
250
+ key: string,
251
+ metadata: {
252
+ readonly etag: string;
253
+ readonly size: number;
254
+ readonly contentType?: string;
255
+ readonly uploadedAtMillis?: number;
256
+ },
257
+ body: ReadableStream<Uint8Array>,
258
+ range?: EdgeObjectRange,
259
+ ) {
260
+ super(key, metadata, range);
261
+ // Bytes only, with no content type attached: a `Response` normalises the
262
+ // header it is given (appending `;charset=utf-8` to a text type, for one),
263
+ // and that normalisation would show up on `blob().type` as a value the Host
264
+ // never stored. The stored content type is read where R2 puts it —
265
+ // `httpMetadata` and `writeHttpMetadata()`.
266
+ this.#response = new Response(body as unknown as BodyInit);
267
+ }
268
+
269
+ get body(): ReadableStream<Uint8Array> {
270
+ // A `Response` built from a stream always has one.
271
+ return this.#response.body as ReadableStream<Uint8Array>;
272
+ }
273
+
274
+ get bodyUsed(): boolean {
275
+ return this.#response.bodyUsed;
276
+ }
277
+
278
+ arrayBuffer(): Promise<ArrayBuffer> {
279
+ return this.#response.arrayBuffer();
280
+ }
281
+
282
+ text(): Promise<string> {
283
+ return this.#response.text();
284
+ }
285
+
286
+ json<T = unknown>(): Promise<T> {
287
+ return this.#response.json() as Promise<T>;
288
+ }
289
+
290
+ blob(): Promise<Blob> {
291
+ return this.#response.blob();
292
+ }
293
+ }
294
+
51
295
  /**
52
296
  * The byte length of a body the Host can be told up front, or `undefined` for
53
297
  * a bare stream whose size only the producer knows.
@@ -61,14 +305,57 @@ function knownBodyLength(value: ObjectStoreBody): number | undefined {
61
305
  return undefined;
62
306
  }
63
307
 
64
- export class EdgeObjectStorage implements ObjectStore {
65
- constructor(private readonly bucket: EdgeObjectsBinding) {}
308
+ /**
309
+ * `edge.objects@1.0.0` as a bucket whose answers are R2's.
310
+ *
311
+ * The calls are the facade's (its option names, its ceilings, its error
312
+ * vocabulary); the results are R2-shaped, so app code written against
313
+ * `R2Bucket` reads them unchanged. See the parity rule at the top of this file
314
+ * for what is provided, what is best effort, and what the wire cannot supply.
315
+ */
316
+ export class EdgeObjectsBucket {
317
+ constructor(private readonly binding: EdgeObjectsBinding) {}
318
+
319
+ async head(key: string): Promise<EdgeR2Object | null> {
320
+ const found: EdgeObjectMetadata | null = await this.binding.head(key);
321
+ if (!found) return null;
322
+ return new EdgeR2ObjectMetadata(key, found);
323
+ }
324
+
325
+ async get(
326
+ key: string,
327
+ options?: EdgeObjectsGetOptions,
328
+ ): Promise<EdgeR2ObjectBody | null> {
329
+ const range = options?.range;
330
+ // Fixed arity: the Host counts `arguments.length`, so the options slot is
331
+ // always passed, even when it is empty.
332
+ const found: EdgeObjectBody | null = await this.binding.get(
333
+ key,
334
+ range === undefined ? undefined : { range },
335
+ );
336
+ if (!found) return null;
337
+ if (found.partial && range === undefined) {
338
+ // No range was asked for, so a partial body would be a truncated object
339
+ // served as if it were whole. Refuse rather than hand the caller bytes
340
+ // that do not add up to the object.
341
+ await found.body.cancel().catch(() => undefined);
342
+ throw new EdgeObjectsShapeError(
343
+ "edge.objects: the Host returned a partial body for an unranged get",
344
+ );
345
+ }
346
+ return new EdgeR2ObjectWithBody(
347
+ key,
348
+ found,
349
+ found.body as ReadableStream<Uint8Array>,
350
+ found.range,
351
+ );
352
+ }
66
353
 
67
354
  async put(
68
355
  key: string,
69
356
  value: ObjectStoreBody,
70
357
  options?: ObjectStorePutOptions,
71
- ): Promise<void> {
358
+ ): Promise<EdgeR2Object> {
72
359
  const contentType = options?.contentType;
73
360
  let contentLength = knownBodyLength(value);
74
361
  // The facade's body slot has no `Blob`. A Blob's stream carries the same
@@ -84,41 +371,89 @@ export class EdgeObjectStorage implements ObjectStore {
84
371
  body = buffered;
85
372
  contentLength = buffered.byteLength;
86
373
  }
87
- await this.bucket.put(key, body, {
374
+ const stored = await this.binding.put(key, body, {
88
375
  contentLength,
89
376
  ...(contentType === undefined ? {} : { contentType }),
90
377
  });
378
+ // The Host's `put` answers with `{etag, size}` and nothing else, so the
379
+ // returned object's `uploaded` is absent — see the parity rule above.
380
+ return new EdgeR2ObjectMetadata(key, {
381
+ etag: stored.etag,
382
+ size: stored.size,
383
+ ...(contentType === undefined ? {} : { contentType }),
384
+ });
385
+ }
386
+
387
+ async delete(key: string | readonly string[]): Promise<void> {
388
+ const keys = typeof key === "string" ? [key] : [...new Set(key)];
389
+ for (const one of keys) await this.binding.delete(one);
390
+ }
391
+
392
+ async list(options?: EdgeObjectsListOptions): Promise<EdgeR2Objects> {
393
+ const page = await this.binding.list(options);
394
+ return {
395
+ objects: page.objects.map(
396
+ (entry) => new EdgeR2ObjectMetadata(entry.key, entry),
397
+ ),
398
+ truncated: page.truncated,
399
+ ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
400
+ // R2 calls the common prefixes a delimiter collapsed `delimitedPrefixes`.
401
+ delimitedPrefixes: page.prefixes,
402
+ };
403
+ }
404
+ }
405
+
406
+ /**
407
+ * The provider-neutral {@link ObjectStore} over the same bucket.
408
+ *
409
+ * This is the port the core's own routes speak, and it stays deliberately
410
+ * narrower than R2 — flat metadata, no enumeration, no separate head — so app
411
+ * code does not grow a dependency on a vendor object shape. Code that WANTS R2
412
+ * takes {@link EdgeObjectsBucket} instead; both run the same adapter, so the
413
+ * media path proves it.
414
+ */
415
+ export class EdgeObjectStorage implements ObjectStore {
416
+ readonly #bucket: EdgeObjectsBucket;
417
+
418
+ constructor(bucket: EdgeObjectsBinding) {
419
+ this.#bucket = new EdgeObjectsBucket(bucket);
420
+ }
421
+
422
+ async put(
423
+ key: string,
424
+ value: ObjectStoreBody,
425
+ options?: ObjectStorePutOptions,
426
+ ): Promise<void> {
427
+ await this.#bucket.put(key, value, options);
91
428
  }
92
429
 
93
430
  async get(key: string): Promise<ObjectStoreObject | null> {
94
- const found = await this.bucket.get(key, undefined);
431
+ const found = await this.#bucket.get(key);
95
432
  if (!found) return null;
96
- if (found.partial) {
97
- // No range was asked for, so a partial body would be a truncated object
98
- // served as if it were whole. Refuse rather than hand the caller bytes
99
- // that do not add up to the object.
100
- await found.body.cancel().catch(() => undefined);
101
- throw new EdgeObjectsShapeError(
102
- "edge.objects: the Host returned a partial body for an unranged get",
103
- );
104
- }
105
433
  return {
106
- key,
107
- body: found.body as ReadableStream<Uint8Array>,
108
- ...(found.contentType === undefined
434
+ key: found.key,
435
+ body: found.body,
436
+ ...(found.httpMetadata.contentType === undefined
109
437
  ? {}
110
- : { contentType: found.contentType }),
438
+ : { contentType: found.httpMetadata.contentType }),
111
439
  etag: found.etag,
112
440
  byteLength: found.size,
113
441
  };
114
442
  }
115
443
 
116
444
  async delete(key: string | readonly string[]): Promise<void> {
117
- const keys = typeof key === "string" ? [key] : [...new Set(key)];
118
- for (const one of keys) await this.bucket.delete(one);
445
+ await this.#bucket.delete(key);
119
446
  }
120
447
  }
121
448
 
449
+ /** Wrap an `edge.objects@1.0.0` binding as an R2-shaped bucket. */
450
+ export function wrapEdgeObjectsAsBucket(
451
+ bucket: EdgeObjectsBinding,
452
+ ): EdgeObjectsBucket {
453
+ return new EdgeObjectsBucket(bucket);
454
+ }
455
+
456
+ /** Wrap an `edge.objects@1.0.0` binding as the provider-neutral port. */
122
457
  export function wrapEdgeObjects(bucket: EdgeObjectsBinding): ObjectStore {
123
458
  return new EdgeObjectStorage(bucket);
124
459
  }