@takosjp/yurucommu-core 3.4.4 → 4.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.
@@ -0,0 +1,380 @@
1
+ import type {
2
+ ObjectStore,
3
+ ObjectStoreBody,
4
+ ObjectStoreObject,
5
+ ObjectStorePutOptions,
6
+ } from "./types.ts";
7
+
8
+ /**
9
+ * The host supplies a Fetch-compatible capability that is already scoped to
10
+ * one object bucket. The adapter never receives (or needs) an endpoint,
11
+ * bucket name, region, or credential.
12
+ */
13
+ export interface S3ObjectFetcher {
14
+ fetch(request: Request): Promise<Response>;
15
+ }
16
+
17
+ export interface S3FetchObjectStoreOptions {
18
+ /** Maximum number of bytes a GET may expose to the application. */
19
+ readonly maxObjectBytes?: number;
20
+ }
21
+
22
+ export type S3FetchObjectOperation = "put" | "get" | "delete";
23
+
24
+ /** An S3 protocol failure without response-body or endpoint details. */
25
+ export class S3FetchObjectStoreError extends Error {
26
+ constructor(
27
+ readonly operation: S3FetchObjectOperation,
28
+ readonly code: string,
29
+ readonly status?: number,
30
+ ) {
31
+ super(code);
32
+ this.name = "S3FetchObjectStoreError";
33
+ }
34
+ }
35
+
36
+ const SYNTHETIC_ORIGIN = "https://s3.invalid";
37
+ const DEFAULT_MAX_OBJECT_BYTES = 64 * 1024 * 1024;
38
+ const MAX_HEADER_VALUE_LENGTH = 8 * 1024;
39
+ const FETCH_REJECTION_CODES: Record<S3FetchObjectOperation, string> = {
40
+ put: "s3_fetcher_put_rejected",
41
+ get: "s3_fetcher_get_rejected",
42
+ delete: "s3_fetcher_delete_rejected",
43
+ };
44
+
45
+ /**
46
+ * Adapt a bucket-scoped S3 HTTP Fetcher to the core's provider-neutral object
47
+ * store contract.
48
+ *
49
+ * The URL is intentionally synthetic. A host-owned Fetcher receives the
50
+ * request and supplies the actual endpoint and credentials internally; no
51
+ * provider materialization can escape through this public adapter.
52
+ */
53
+ export function createS3FetchObjectStore(
54
+ fetcher: S3ObjectFetcher,
55
+ options: S3FetchObjectStoreOptions = {},
56
+ ): ObjectStore {
57
+ if (!fetcher || typeof fetcher.fetch !== "function") {
58
+ throw new TypeError("s3_fetcher_invalid");
59
+ }
60
+ const maxObjectBytes = options.maxObjectBytes ?? DEFAULT_MAX_OBJECT_BYTES;
61
+ assertMaxObjectBytes(maxObjectBytes);
62
+
63
+ return new S3FetchObjectStore(fetcher, maxObjectBytes);
64
+ }
65
+
66
+ class S3FetchObjectStore implements ObjectStore {
67
+ constructor(
68
+ private readonly fetcher: S3ObjectFetcher,
69
+ private readonly maxObjectBytes: number,
70
+ ) {}
71
+
72
+ async put(
73
+ key: string,
74
+ value: ObjectStoreBody,
75
+ options?: ObjectStorePutOptions,
76
+ ): Promise<void> {
77
+ const headers = new Headers();
78
+ if (options?.contentType !== undefined) {
79
+ setBoundedHeader(headers, "content-type", options.contentType, "put");
80
+ }
81
+ const byteLength = knownBodyLength(value);
82
+ if (byteLength !== undefined) {
83
+ headers.set("content-length", String(byteLength));
84
+ }
85
+
86
+ const response = await fetchFromS3(
87
+ this.fetcher,
88
+ new Request(objectUrl(key), {
89
+ method: "PUT",
90
+ headers,
91
+ body: value as BodyInit,
92
+ }),
93
+ "put",
94
+ );
95
+ await expectSuccess(response, "put");
96
+ }
97
+
98
+ async get(key: string): Promise<ObjectStoreObject | null> {
99
+ const response = await fetchFromS3(
100
+ this.fetcher,
101
+ new Request(objectUrl(key), { method: "GET" }),
102
+ "get",
103
+ );
104
+ if (response.status === 404) {
105
+ await cancelBody(response);
106
+ return null;
107
+ }
108
+ await expectSuccess(response, "get", false);
109
+
110
+ let byteLength: number | undefined;
111
+ let contentType: string | undefined;
112
+ let etag: string | undefined;
113
+ try {
114
+ byteLength = parseContentLength(
115
+ response.headers.get("content-length"),
116
+ this.maxObjectBytes,
117
+ "get",
118
+ response.status,
119
+ );
120
+ contentType = boundedHeader(
121
+ response.headers,
122
+ "content-type",
123
+ "get",
124
+ response.status,
125
+ );
126
+ etag = boundedHeader(response.headers, "etag", "get", response.status);
127
+ } catch (error) {
128
+ await cancelBody(response);
129
+ throw error;
130
+ }
131
+ return {
132
+ key,
133
+ body:
134
+ response.body === null
135
+ ? null
136
+ : boundedBody(response.body, this.maxObjectBytes, "get"),
137
+ ...(contentType === undefined ? {} : { contentType }),
138
+ ...(etag === undefined ? {} : { etag }),
139
+ ...(byteLength === undefined ? {} : { byteLength }),
140
+ };
141
+ }
142
+
143
+ async delete(key: string | readonly string[]): Promise<void> {
144
+ const isBatch = typeof key !== "string";
145
+ const keys = [...new Set(isBatch ? key : [key])];
146
+ const failures: S3FetchObjectStoreError[] = [];
147
+ for (const entry of keys) {
148
+ try {
149
+ const response = await fetchFromS3(
150
+ this.fetcher,
151
+ new Request(objectUrl(entry), { method: "DELETE" }),
152
+ "delete",
153
+ );
154
+ await expectSuccess(response, "delete");
155
+ } catch (error) {
156
+ failures.push(
157
+ error instanceof S3FetchObjectStoreError
158
+ ? error
159
+ : new S3FetchObjectStoreError(
160
+ "delete",
161
+ "s3_delete_operation_failed",
162
+ ),
163
+ );
164
+ }
165
+ }
166
+ if (failures.length === 0) return;
167
+ if (!isBatch) {
168
+ throw failures[0];
169
+ }
170
+ throw new AggregateError(failures, "s3_batch_delete_failed");
171
+ }
172
+ }
173
+
174
+ async function fetchFromS3(
175
+ fetcher: S3ObjectFetcher,
176
+ request: Request,
177
+ operation: S3FetchObjectOperation,
178
+ ): Promise<Response> {
179
+ try {
180
+ return await fetcher.fetch(request);
181
+ } catch {
182
+ // Never retain the supplied rejection as `cause` or copy any of its text:
183
+ // the host Fetcher may contain endpoint and credential diagnostics.
184
+ throw new S3FetchObjectStoreError(
185
+ operation,
186
+ FETCH_REJECTION_CODES[operation],
187
+ );
188
+ }
189
+ }
190
+
191
+ function objectUrl(key: string): string {
192
+ return `${SYNTHETIC_ORIGIN}/${key
193
+ .split("/")
194
+ .map(encodePathSegment)
195
+ .join("/")}`;
196
+ }
197
+
198
+ /** Encode every path segment, including dot segments, so URL normalization
199
+ * cannot reinterpret a user key as traversal. */
200
+ function encodePathSegment(value: string): string {
201
+ const encoded = encodeURIComponent(value).replace(
202
+ /[.!'()*]/gu,
203
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
204
+ );
205
+ // URL parsers normalize `%2E`/`%2E%2E` path segments as traversal before a
206
+ // Fetcher sees the request. Double-escape only those complete segments so
207
+ // the host can decode the wire path without losing the key boundary.
208
+ return value === "." || value === ".."
209
+ ? encoded.replace(/%2E/gu, "%252E")
210
+ : encoded;
211
+ }
212
+
213
+ function knownBodyLength(value: ObjectStoreBody): number | undefined {
214
+ if (value instanceof Blob) return value.size;
215
+ if (value instanceof ArrayBuffer) return value.byteLength;
216
+ if (typeof value === "string") {
217
+ return new TextEncoder().encode(value).byteLength;
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ async function expectSuccess(
223
+ response: Response,
224
+ operation: S3FetchObjectOperation,
225
+ cancelSuccessfulBody = true,
226
+ ): Promise<void> {
227
+ if (response.status >= 200 && response.status < 300) {
228
+ if (cancelSuccessfulBody) await cancelBody(response);
229
+ return;
230
+ }
231
+ await cancelBody(response);
232
+ throw new S3FetchObjectStoreError(
233
+ operation,
234
+ "s3_response_unexpected_status",
235
+ response.status,
236
+ );
237
+ }
238
+
239
+ async function cancelBody(response: Response): Promise<void> {
240
+ await response.body?.cancel().catch(() => undefined);
241
+ }
242
+
243
+ function boundedHeader(
244
+ headers: Headers,
245
+ name: string,
246
+ operation: S3FetchObjectOperation,
247
+ status: number,
248
+ ): string | undefined {
249
+ const value = headers.get(name);
250
+ if (value === null || value.length === 0) return undefined;
251
+ if (value.length > MAX_HEADER_VALUE_LENGTH) {
252
+ throw new S3FetchObjectStoreError(
253
+ operation,
254
+ "s3_response_header_too_large",
255
+ status,
256
+ );
257
+ }
258
+ return value;
259
+ }
260
+
261
+ function setBoundedHeader(
262
+ headers: Headers,
263
+ name: string,
264
+ value: string,
265
+ operation: S3FetchObjectOperation,
266
+ ): void {
267
+ if (value.length > MAX_HEADER_VALUE_LENGTH) {
268
+ throw new S3FetchObjectStoreError(operation, "s3_request_header_too_large");
269
+ }
270
+ headers.set(name, value);
271
+ }
272
+
273
+ function parseContentLength(
274
+ value: string | null,
275
+ maxObjectBytes: number,
276
+ operation: S3FetchObjectOperation,
277
+ status: number,
278
+ ): number | undefined {
279
+ if (value === null) return undefined;
280
+ if (
281
+ value.length > MAX_HEADER_VALUE_LENGTH ||
282
+ !/^\d+$/u.test(value) ||
283
+ !Number.isSafeInteger(Number(value))
284
+ ) {
285
+ throw new S3FetchObjectStoreError(
286
+ operation,
287
+ "s3_response_content_length_invalid",
288
+ status,
289
+ );
290
+ }
291
+ const parsed = Number(value);
292
+ if (parsed > maxObjectBytes) {
293
+ throw new S3FetchObjectStoreError(
294
+ operation,
295
+ "s3_response_object_too_large",
296
+ status,
297
+ );
298
+ }
299
+ return parsed;
300
+ }
301
+
302
+ function boundedBody(
303
+ stream: ReadableStream<Uint8Array>,
304
+ maxObjectBytes: number,
305
+ operation: S3FetchObjectOperation,
306
+ ): ReadableStream<Uint8Array> {
307
+ const reader = stream.getReader();
308
+ let size = 0;
309
+ let finished = false;
310
+ const release = (): void => {
311
+ if (finished) return;
312
+ finished = true;
313
+ try {
314
+ reader.releaseLock();
315
+ } catch {
316
+ // The bounded adapter owns no useful diagnostic at this boundary. A
317
+ // lock-release failure after read/cancel completion must not surface a
318
+ // provider error or retain its cause.
319
+ }
320
+ };
321
+
322
+ return new ReadableStream<Uint8Array>(
323
+ {
324
+ async pull(controller) {
325
+ try {
326
+ const result = await reader.read();
327
+ if (result.done) {
328
+ controller.close();
329
+ release();
330
+ return;
331
+ }
332
+ size += result.value.byteLength;
333
+ if (size > maxObjectBytes) {
334
+ await reader
335
+ .cancel("s3_response_object_too_large")
336
+ .catch(() => undefined);
337
+ controller.error(
338
+ new S3FetchObjectStoreError(
339
+ operation,
340
+ "s3_response_object_too_large",
341
+ ),
342
+ );
343
+ release();
344
+ return;
345
+ }
346
+ controller.enqueue(result.value);
347
+ } catch {
348
+ controller.error(
349
+ new S3FetchObjectStoreError(
350
+ operation,
351
+ "s3_response_body_read_failed",
352
+ ),
353
+ );
354
+ release();
355
+ }
356
+ },
357
+ async cancel() {
358
+ try {
359
+ // Do not forward an arbitrary caller reason into a provider-owned
360
+ // stream, and never retain a provider rejection as cause/text.
361
+ await reader.cancel("s3_response_body_cancelled");
362
+ } catch {
363
+ throw new S3FetchObjectStoreError(
364
+ operation,
365
+ "s3_response_body_cancel_failed",
366
+ );
367
+ } finally {
368
+ release();
369
+ }
370
+ },
371
+ },
372
+ { highWaterMark: 0 },
373
+ );
374
+ }
375
+
376
+ function assertMaxObjectBytes(value: number): void {
377
+ if (!Number.isSafeInteger(value) || value < 1) {
378
+ throw new TypeError("s3_object_response_limit_invalid");
379
+ }
380
+ }
@@ -58,82 +58,50 @@ export interface IDatabase {
58
58
  }
59
59
 
60
60
  /**
61
- * Object storage metadata
61
+ * Bodies accepted by the provider-neutral object store seam.
62
+ *
63
+ * A body is handed to an adapter exactly once. Adapters must not eagerly
64
+ * consume or clone streams; callers that need replayability own that concern.
62
65
  */
63
- export interface ObjectMetadata {
66
+ export type ObjectStoreBody =
67
+ Blob | ReadableStream<Uint8Array> | ArrayBuffer | string;
68
+
69
+ /** Options for writing one object. */
70
+ export interface ObjectStorePutOptions {
64
71
  contentType?: string;
65
- contentLength?: number;
66
- etag?: string;
67
- httpMetadata?: {
68
- contentType?: string;
69
- cacheControl?: string;
70
- contentDisposition?: string;
71
- contentEncoding?: string;
72
- contentLanguage?: string;
73
- };
74
- customMetadata?: Record<string, string>;
75
72
  }
76
73
 
77
74
  /**
78
- * Storage object interface
75
+ * One object returned by an ObjectStore read.
76
+ *
77
+ * The body remains lazy and is consumed through the returned stream. Metadata
78
+ * is deliberately flat so callers do not depend on a vendor SDK's shape.
79
79
  */
80
- export interface StorageObject {
80
+ export interface ObjectStoreObject {
81
81
  key: string;
82
- body: ReadableStream | null;
83
- bodyUsed: boolean;
84
- /**
85
- * HTTP `ETag` value to send to clients. Optional because not every
86
- * backend (e.g. the in-memory test stub) tracks an etag.
87
- */
88
- httpEtag?: string;
89
- arrayBuffer(): Promise<ArrayBuffer>;
90
- text(): Promise<string>;
91
- json<T = unknown>(): Promise<T>;
92
- httpMetadata?: ObjectMetadata["httpMetadata"];
93
- customMetadata?: Record<string, string>;
94
- }
95
-
96
- /**
97
- * List objects result
98
- */
99
- export interface ListObjectsResult {
100
- objects: Array<{
101
- key: string;
102
- size: number;
103
- uploaded: Date;
104
- etag?: string;
105
- httpMetadata?: ObjectMetadata["httpMetadata"];
106
- }>;
107
- truncated: boolean;
108
- cursor?: string;
109
- delimitedPrefixes?: string[];
82
+ body: ReadableStream<Uint8Array> | null;
83
+ contentType?: string;
84
+ etag?: string;
85
+ byteLength?: number;
110
86
  }
111
87
 
112
88
  /**
113
- * Object storage interface - abstracts R2Bucket
89
+ * Provider-neutral object storage seam.
90
+ *
91
+ * Implementations expose only the operations used by production code. Object
92
+ * enumeration and separate metadata probes are intentionally not part of the
93
+ * contract; batch deletion is represented by passing an array of keys.
114
94
  */
115
- export interface IObjectStorage {
95
+ export interface ObjectStore {
116
96
  put(
117
97
  key: string,
118
- value: ReadableStream | ArrayBuffer | string,
119
- options?: {
120
- httpMetadata?: ObjectMetadata["httpMetadata"];
121
- customMetadata?: Record<string, string>;
122
- },
98
+ value: ObjectStoreBody,
99
+ options?: ObjectStorePutOptions,
123
100
  ): Promise<void>;
124
101
 
125
- get(key: string): Promise<StorageObject | null>;
126
-
127
- delete(key: string | string[]): Promise<void>;
128
-
129
- list(options?: {
130
- prefix?: string;
131
- limit?: number;
132
- cursor?: string;
133
- delimiter?: string;
134
- }): Promise<ListObjectsResult>;
102
+ get(key: string): Promise<ObjectStoreObject | null>;
135
103
 
136
- head(key: string): Promise<ObjectMetadata | null>;
104
+ delete(key: string | readonly string[]): Promise<void>;
137
105
  }
138
106
 
139
107
  /**
@@ -182,7 +150,7 @@ export interface IStaticAssets {
182
150
  */
183
151
  export interface RuntimeEnv {
184
152
  db: IDatabase;
185
- storage?: IObjectStorage;
153
+ storage?: ObjectStore;
186
154
  kv?: IKeyValueStore;
187
155
  assets?: IStaticAssets;
188
156
 
@@ -1,7 +1,7 @@
1
1
  import type { Database } from "../db/index.ts";
2
2
  import type {
3
3
  IKeyValueStore,
4
- IObjectStorage,
4
+ ObjectStore,
5
5
  IStaticAssets,
6
6
  } from "./runtime/types.ts";
7
7
  import type { IQueueProducer } from "./runtime/queue.ts";
@@ -133,8 +133,8 @@ export interface EnvVars {
133
133
  /**
134
134
  * Application Environment
135
135
  *
136
- * Uses the runtime-neutral `I*` contracts. The Cloudflare worker entry
137
- * wraps the native `D1Database` / `R2Bucket` / `KVNamespace` / `Fetcher`
136
+ * Uses the runtime-neutral contracts. The Cloudflare worker entry
137
+ * wraps the native `D1Database` / object bucket / `KVNamespace` / `Fetcher`
138
138
  * bindings with the adapters in `runtime/cloudflare.ts` before handing
139
139
  * the Env to Hono. The local runtime compatibility classes already
140
140
  * implement these contracts directly.
@@ -145,7 +145,7 @@ export interface EnvVars {
145
145
  */
146
146
  export type Env = {
147
147
  DB_INSTANCE: Database;
148
- MEDIA?: IObjectStorage;
148
+ MEDIA?: ObjectStore;
149
149
  KV: IKeyValueStore;
150
150
  ASSETS?: IStaticAssets;
151
151
  DELIVERY_QUEUE?: IQueueProducer<DeliveryQueueMessageV1>;