@agentuity/storage 3.0.0-alpha.7

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,105 @@
1
+ /**
2
+ * Shared types for `@agentuity/storage`.
3
+ *
4
+ * Both the Bun-backed and Node-backed implementations conform to the
5
+ * `S3ClientLike` interface. Callers should program against this surface
6
+ * rather than backend-specific shapes, so that swapping the backend (or
7
+ * letting the package conditionally pick one) is transparent.
8
+ */
9
+ /** Bucket connection configuration. */
10
+ export interface BucketConfig {
11
+ /**
12
+ * Bucket-specific endpoint, e.g. `my-bucket.agentuity.run`. May be
13
+ * provided with or without a scheme; missing schemes default to
14
+ * `https://`.
15
+ */
16
+ endpoint: string;
17
+ /** S3 access key ID. */
18
+ access_key: string;
19
+ /** S3 secret access key. */
20
+ secret_key: string;
21
+ /** Optional region. Defaults to `'auto'` when omitted or null. */
22
+ region?: string | null;
23
+ }
24
+ /** Options for `list()`. */
25
+ export interface S3ListOptions {
26
+ /** Only return objects whose key starts with this prefix. */
27
+ prefix?: string;
28
+ /** Maximum number of objects to return in this response. */
29
+ maxKeys?: number;
30
+ }
31
+ /** A single object entry in a list result. */
32
+ export interface S3Object {
33
+ key: string;
34
+ size: number;
35
+ /**
36
+ * ISO 8601 timestamp string. Both backends normalize to a string so
37
+ * downstream code does not have to branch on `Date | string`.
38
+ */
39
+ lastModified: string;
40
+ etag?: string;
41
+ }
42
+ /** Result of a `list()` call. */
43
+ export interface S3ListResult {
44
+ contents: S3Object[];
45
+ isTruncated: boolean;
46
+ }
47
+ /** Result of a `stat()` (HEAD) call. */
48
+ export interface S3StatResult {
49
+ size: number;
50
+ /** Content-Type of the object, when present. */
51
+ type?: string;
52
+ lastModified?: Date;
53
+ etag?: string;
54
+ }
55
+ /** Options for `write()`. */
56
+ export interface S3WriteOptions {
57
+ /** Content-Type to record on the object. */
58
+ type?: string;
59
+ }
60
+ /**
61
+ * Anything we can upload as an object body.
62
+ *
63
+ * The Bun backend forwards these directly to `Bun.S3Client.write`, which
64
+ * accepts the same shapes. The Node backend converts them to formats
65
+ * accepted by `@aws-sdk/client-s3`'s `PutObjectCommand`.
66
+ */
67
+ export type S3Body = string | Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>;
68
+ /** A handle to an object on the server. Returned by `client.file(key)`. */
69
+ export interface S3FileLike {
70
+ /** Read the entire object into memory as an `ArrayBuffer`. */
71
+ arrayBuffer(): Promise<ArrayBuffer>;
72
+ /** Read the entire object as UTF-8 text. */
73
+ text(): Promise<string>;
74
+ /** Get a Web `ReadableStream` for the object body. */
75
+ stream(): ReadableStream<Uint8Array>;
76
+ }
77
+ /**
78
+ * Unified S3 client interface.
79
+ *
80
+ * Implemented by both `@agentuity/storage/bun` and
81
+ * `@agentuity/storage/node`. The surface mirrors `Bun.S3Client` so the
82
+ * Bun backend can be a thin wrapper, and the Node backend translates
83
+ * to `@aws-sdk/client-s3` commands.
84
+ */
85
+ export interface S3ClientLike {
86
+ /**
87
+ * List objects in the bucket. Pass `null` (or omit) to list from the
88
+ * root with no prefix; otherwise narrow with `{ prefix, maxKeys }`.
89
+ */
90
+ list(opts?: S3ListOptions | null): Promise<S3ListResult>;
91
+ /** Get object metadata (HEAD). */
92
+ stat(key: string): Promise<S3StatResult>;
93
+ /** Get a handle to an object for streaming reads. */
94
+ file(key: string): S3FileLike;
95
+ /**
96
+ * Upload an object. Returns the number of bytes written.
97
+ *
98
+ * For streaming bodies, the byte count is measured by a counting
99
+ * pass-through stream so that the return value is always accurate.
100
+ */
101
+ write(key: string, body: S3Body, opts?: S3WriteOptions): Promise<number>;
102
+ /** Delete an object. No-op (does not throw) if the object is absent. */
103
+ delete(key: string): Promise<void>;
104
+ }
105
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,uCAAuC;AACvC,MAAM,WAAW,YAAY;IAC5B;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,wBAAwB;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,4BAA4B;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,kEAAkE;IAClE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,4BAA4B;AAC5B,MAAM,WAAW,aAAa;IAC7B,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,8CAA8C;AAC9C,MAAM,WAAW,QAAQ;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,iCAAiC;AACjC,MAAM,WAAW,YAAY;IAC5B,QAAQ,EAAE,QAAQ,EAAE,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;CACrB;AAED,wCAAwC;AACxC,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,IAAI,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,6BAA6B;AAC7B,MAAM,WAAW,cAAc;IAC9B,4CAA4C;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;GAMG;AACH,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,IAAI,GAAG,cAAc,CAAC,UAAU,CAAC,CAAC;AAE3F,2EAA2E;AAC3E,MAAM,WAAW,UAAU;IAC1B,8DAA8D;IAC9D,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IACpC,4CAA4C;IAC5C,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IACxB,sDAAsD;IACtD,MAAM,IAAI,cAAc,CAAC,UAAU,CAAC,CAAC;CACrC;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC5B;;;OAGG;IACH,IAAI,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,IAAI,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACzD,kCAAkC;IAClC,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACzC,qDAAqD;IACrD,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAAC;IAC9B;;;;;OAKG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACzE,wEAAwE;IACxE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnC"}
package/dist/types.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared types for `@agentuity/storage`.
3
+ *
4
+ * Both the Bun-backed and Node-backed implementations conform to the
5
+ * `S3ClientLike` interface. Callers should program against this surface
6
+ * rather than backend-specific shapes, so that swapping the backend (or
7
+ * letting the package conditionally pick one) is transparent.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@agentuity/storage",
3
+ "version": "3.0.0-alpha.7",
4
+ "license": "Apache-2.0",
5
+ "author": "Agentuity employees and contributors",
6
+ "description": "Dual-runtime S3 client for Agentuity storage buckets. Bun backend uses Bun.S3Client; Node backend uses @aws-sdk/client-s3.",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "files": [
11
+ "AGENTS.md",
12
+ "README.md",
13
+ "src",
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "bun": {
19
+ "types": "./dist/bun.d.ts",
20
+ "import": "./dist/bun.js"
21
+ },
22
+ "node": {
23
+ "types": "./dist/node.d.ts",
24
+ "import": "./dist/node.js"
25
+ },
26
+ "default": {
27
+ "types": "./dist/node.d.ts",
28
+ "import": "./dist/node.js"
29
+ }
30
+ },
31
+ "./bun": {
32
+ "types": "./dist/bun.d.ts",
33
+ "import": "./dist/bun.js"
34
+ },
35
+ "./node": {
36
+ "types": "./dist/node.d.ts",
37
+ "import": "./dist/node.js"
38
+ },
39
+ "./types": {
40
+ "types": "./dist/types.d.ts",
41
+ "import": "./dist/types.js"
42
+ }
43
+ },
44
+ "scripts": {
45
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
46
+ "build": "bunx tsc --build --force",
47
+ "typecheck": "bunx tsc --noEmit",
48
+ "prepublishOnly": "bun run clean && bun run build"
49
+ },
50
+ "dependencies": {
51
+ "@aws-sdk/client-s3": "^3.700.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/bun": "latest",
55
+ "bun-types": "latest",
56
+ "typescript": "^5.9.0"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "sideEffects": false,
62
+ "repository": {
63
+ "type": "git",
64
+ "url": "git+https://github.com/agentuity/sdk.git",
65
+ "directory": "packages/storage"
66
+ }
67
+ }
package/src/bun.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Bun-backed implementation of `S3ClientLike`.
3
+ *
4
+ * Thin wrapper around `Bun.S3Client`. Only resolvable when the consumer
5
+ * is running under Bun — `package.json`'s `"bun"` exports condition
6
+ * routes here automatically; explicit `@agentuity/storage/bun` imports
7
+ * also land here.
8
+ *
9
+ * Falling through to this module under Node will fail at import time
10
+ * because `import { S3Client } from 'bun'` is not satisfiable there.
11
+ * That is intentional and matches the contract of the `bun` exports
12
+ * condition.
13
+ */
14
+
15
+ import { S3Client } from 'bun';
16
+ import type {
17
+ BucketConfig,
18
+ S3Body,
19
+ S3ClientLike,
20
+ S3FileLike,
21
+ S3ListOptions,
22
+ S3ListResult,
23
+ S3StatResult,
24
+ S3WriteOptions,
25
+ } from './types.ts';
26
+
27
+ export type { BucketConfig, S3ClientLike } from './types.ts';
28
+
29
+ /**
30
+ * Create an S3 client backed by `Bun.S3Client`.
31
+ *
32
+ * Endpoints are bucket-scoped (virtual-hosted-style), so we do not pass
33
+ * a bucket parameter on the client itself; the hostname routes to the
34
+ * correct bucket.
35
+ */
36
+ export function createS3Client(bucket: BucketConfig): S3ClientLike {
37
+ const endpoint = bucket.endpoint.startsWith('http')
38
+ ? bucket.endpoint
39
+ : `https://${bucket.endpoint}`;
40
+
41
+ const client = new S3Client({
42
+ endpoint,
43
+ accessKeyId: bucket.access_key,
44
+ secretAccessKey: bucket.secret_key,
45
+ region: bucket.region || 'auto',
46
+ virtualHostedStyle: true,
47
+ });
48
+
49
+ return {
50
+ async list(opts?: S3ListOptions | null): Promise<S3ListResult> {
51
+ // `Bun.S3Client.list` accepts `null` for "no filter".
52
+ const out = (await client.list(opts ?? (null as any))) as {
53
+ contents?: Array<{
54
+ key: string;
55
+ size?: number;
56
+ lastModified?: string | Date;
57
+ etag?: string;
58
+ }>;
59
+ isTruncated?: boolean;
60
+ };
61
+ return {
62
+ contents: (out.contents ?? []).map((o) => ({
63
+ key: o.key,
64
+ size: o.size ?? 0,
65
+ lastModified: normalizeTimestamp(o.lastModified),
66
+ etag: o.etag,
67
+ })),
68
+ isTruncated: out.isTruncated ?? false,
69
+ };
70
+ },
71
+
72
+ async stat(key: string): Promise<S3StatResult> {
73
+ const out = await client.stat(key);
74
+ return {
75
+ size: out.size ?? 0,
76
+ type: out.type,
77
+ lastModified: out.lastModified,
78
+ // Bun's stat returns the etag with mixed casing across versions.
79
+ etag: (out as any).etag ?? (out as any).ETag,
80
+ };
81
+ },
82
+
83
+ file(key: string): S3FileLike {
84
+ const f = client.file(key);
85
+ return {
86
+ arrayBuffer: () => f.arrayBuffer(),
87
+ text: () => f.text(),
88
+ stream: () => f.stream() as ReadableStream<Uint8Array>,
89
+ };
90
+ },
91
+
92
+ async write(key: string, body: S3Body, opts?: S3WriteOptions): Promise<number> {
93
+ // `Bun.S3Client.write` accepts string, Uint8Array, ArrayBuffer,
94
+ // Blob, ReadableStream, or Response. We accept the same shapes
95
+ // minus Response (callers should pass the underlying stream).
96
+ return client.write(key, body as any, opts);
97
+ },
98
+
99
+ async delete(key: string): Promise<void> {
100
+ await client.delete(key);
101
+ },
102
+ };
103
+ }
104
+
105
+ function normalizeTimestamp(value: string | Date | undefined): string {
106
+ if (!value) return '';
107
+ if (typeof value === 'string') return value;
108
+ return value.toISOString();
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Default entry for `@agentuity/storage`.
3
+ *
4
+ * The real backend selection happens in `package.json`'s `exports`
5
+ * conditions: under Bun the `"bun"` condition routes the bare import
6
+ * to `./bun.js`; under Node (and other resolvers that respect the
7
+ * `"node"` condition) it routes to `./node.js`.
8
+ *
9
+ * This file exists for resolvers that ignore conditions entirely (some
10
+ * IDE indexers, certain bundler configurations, or callers using the
11
+ * legacy `main` field). For those, we re-export the Node backend,
12
+ * which works under both runtimes.
13
+ */
14
+
15
+ export * from './node.ts';
package/src/node.ts ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Node-backed implementation of `S3ClientLike`.
3
+ *
4
+ * Wraps `@aws-sdk/client-s3`. Resolvable on both Node and Bun, so it
5
+ * can serve as the package's default fallback for resolvers that do
6
+ * not honor the `"bun"` exports condition.
7
+ *
8
+ * Design notes:
9
+ *
10
+ * 1. **Lazy SDK loading.** `@aws-sdk/client-s3` is large (~1 MB on
11
+ * disk) and imports several hundred files at startup. To keep the
12
+ * cost off the cold-start path of callers that import this module
13
+ * but never actually upload/download, the SDK is loaded via dynamic
14
+ * `import()` on first method call.
15
+ *
16
+ * 2. **Streamed uploads track byte counts via a counting passthrough.**
17
+ * `PutObjectCommand` does not report bytes uploaded. For
18
+ * fixed-size bodies (`Uint8Array`, `ArrayBuffer`, `Buffer`,
19
+ * `string`, `Blob`) we use `.byteLength` / `.size`. For
20
+ * `ReadableStream` bodies we wrap the source in a counting
21
+ * `Transform` so the returned byte count matches what was actually
22
+ * sent on the wire.
23
+ *
24
+ * 3. **Bucket-in-endpoint addressing.** Agentuity buckets use
25
+ * virtual-host-style endpoints (`<bucket>.<host>`), so the bucket
26
+ * name is implicit in the URL. The SDK still requires a `Bucket`
27
+ * parameter on each command; we extract it from the endpoint's
28
+ * leading hostname label and rely on the endpoint override to route
29
+ * correctly. `forcePathStyle` is `false`.
30
+ */
31
+
32
+ import { Buffer } from 'node:buffer';
33
+ import { Readable, Transform } from 'node:stream';
34
+ import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web';
35
+ import type {
36
+ BucketConfig,
37
+ S3Body,
38
+ S3ClientLike,
39
+ S3FileLike,
40
+ S3ListOptions,
41
+ S3ListResult,
42
+ S3StatResult,
43
+ S3WriteOptions,
44
+ } from './types.ts';
45
+
46
+ export type { BucketConfig, S3ClientLike } from './types.ts';
47
+
48
+ // Lazy-loaded handle to `@aws-sdk/client-s3`. Populated on first call to
49
+ // `loadSdk()`; subsequent calls reuse the cached module.
50
+ type SdkModule = typeof import('@aws-sdk/client-s3');
51
+ let sdkModule: SdkModule | null = null;
52
+ let sdkPromise: Promise<SdkModule> | null = null;
53
+
54
+ async function loadSdk(): Promise<SdkModule> {
55
+ if (sdkModule) return sdkModule;
56
+ if (!sdkPromise) {
57
+ sdkPromise = import('@aws-sdk/client-s3').then((mod) => {
58
+ sdkModule = mod;
59
+ return mod;
60
+ });
61
+ }
62
+ return sdkPromise;
63
+ }
64
+
65
+ interface InternalState {
66
+ endpoint: string;
67
+ bucketLabel: string;
68
+ region: string;
69
+ credentials: { accessKeyId: string; secretAccessKey: string };
70
+ /** Cached SDK client; created on first use. */
71
+ clientPromise: Promise<import('@aws-sdk/client-s3').S3Client> | null;
72
+ }
73
+
74
+ export function createS3Client(bucket: BucketConfig): S3ClientLike {
75
+ const endpoint = bucket.endpoint.startsWith('http')
76
+ ? bucket.endpoint
77
+ : `https://${bucket.endpoint}`;
78
+
79
+ // Extract the leading hostname label as the bucket name. The endpoint
80
+ // is virtual-hosted-style (`<bucket>.<host>`), so the SDK's `Bucket`
81
+ // parameter is essentially a placeholder — what actually routes the
82
+ // request is the `endpoint` URL.
83
+ const bucketLabel = extractBucketLabel(endpoint);
84
+
85
+ const state: InternalState = {
86
+ endpoint,
87
+ bucketLabel,
88
+ region: bucket.region || 'auto',
89
+ credentials: {
90
+ accessKeyId: bucket.access_key,
91
+ secretAccessKey: bucket.secret_key,
92
+ },
93
+ clientPromise: null,
94
+ };
95
+
96
+ const getClient = async () => {
97
+ if (!state.clientPromise) {
98
+ const sdk = await loadSdk();
99
+ state.clientPromise = Promise.resolve(
100
+ new sdk.S3Client({
101
+ endpoint: state.endpoint,
102
+ region: state.region,
103
+ credentials: state.credentials,
104
+ forcePathStyle: false,
105
+ })
106
+ );
107
+ }
108
+ return state.clientPromise;
109
+ };
110
+
111
+ return {
112
+ async list(opts?: S3ListOptions | null): Promise<S3ListResult> {
113
+ const sdk = await loadSdk();
114
+ const client = await getClient();
115
+ const out = await client.send(
116
+ new sdk.ListObjectsV2Command({
117
+ Bucket: state.bucketLabel,
118
+ Prefix: opts?.prefix,
119
+ MaxKeys: opts?.maxKeys,
120
+ })
121
+ );
122
+ return {
123
+ contents: (out.Contents ?? []).map((o) => ({
124
+ key: o.Key ?? '',
125
+ size: o.Size ?? 0,
126
+ lastModified: o.LastModified?.toISOString() ?? '',
127
+ etag: o.ETag,
128
+ })),
129
+ isTruncated: out.IsTruncated ?? false,
130
+ };
131
+ },
132
+
133
+ async stat(key: string): Promise<S3StatResult> {
134
+ const sdk = await loadSdk();
135
+ const client = await getClient();
136
+ const out = await client.send(
137
+ new sdk.HeadObjectCommand({ Bucket: state.bucketLabel, Key: key })
138
+ );
139
+ return {
140
+ size: out.ContentLength ?? 0,
141
+ type: out.ContentType,
142
+ lastModified: out.LastModified,
143
+ etag: out.ETag,
144
+ };
145
+ },
146
+
147
+ file(key: string): S3FileLike {
148
+ return {
149
+ async arrayBuffer(): Promise<ArrayBuffer> {
150
+ const sdk = await loadSdk();
151
+ const client = await getClient();
152
+ const out = await client.send(
153
+ new sdk.GetObjectCommand({ Bucket: state.bucketLabel, Key: key })
154
+ );
155
+ return readBodyAsArrayBuffer(out.Body);
156
+ },
157
+ async text(): Promise<string> {
158
+ const buf = await this.arrayBuffer();
159
+ return new TextDecoder().decode(buf);
160
+ },
161
+ stream(): ReadableStream<Uint8Array> {
162
+ // Lazily fetch on first pull so callers can hold a handle
163
+ // without triggering a network call upfront.
164
+ let inner: ReadableStream<Uint8Array> | null = null;
165
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
166
+ return new ReadableStream<Uint8Array>({
167
+ async pull(controller) {
168
+ if (!inner) {
169
+ const sdk = await loadSdk();
170
+ const client = await getClient();
171
+ const out = await client.send(
172
+ new sdk.GetObjectCommand({
173
+ Bucket: state.bucketLabel,
174
+ Key: key,
175
+ })
176
+ );
177
+ inner = bodyToWebStream(out.Body);
178
+ reader = inner.getReader() as ReadableStreamDefaultReader<Uint8Array>;
179
+ }
180
+ const { value, done } = await reader!.read();
181
+ if (done) controller.close();
182
+ else controller.enqueue(value);
183
+ },
184
+ async cancel() {
185
+ await reader?.cancel();
186
+ },
187
+ });
188
+ },
189
+ };
190
+ },
191
+
192
+ async write(key: string, body: S3Body, opts?: S3WriteOptions): Promise<number> {
193
+ const sdk = await loadSdk();
194
+ const client = await getClient();
195
+ const { Body, getBytesUploaded } = prepareBody(body);
196
+ await client.send(
197
+ new sdk.PutObjectCommand({
198
+ Bucket: state.bucketLabel,
199
+ Key: key,
200
+ Body,
201
+ ContentType: opts?.type,
202
+ })
203
+ );
204
+ return getBytesUploaded();
205
+ },
206
+
207
+ async delete(key: string): Promise<void> {
208
+ const sdk = await loadSdk();
209
+ const client = await getClient();
210
+ await client.send(new sdk.DeleteObjectCommand({ Bucket: state.bucketLabel, Key: key }));
211
+ },
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Convert one of our accepted `S3Body` shapes into something the AWS
217
+ * SDK can consume, while also preparing a way to report the number of
218
+ * bytes that flowed to S3.
219
+ *
220
+ * For fixed-size bodies the count is known up front. For streaming
221
+ * bodies we attach a counting `Transform` and read the tally back after
222
+ * the upload completes.
223
+ */
224
+ function prepareBody(body: S3Body): {
225
+ Body: Uint8Array | Buffer | Readable | string;
226
+ getBytesUploaded(): number;
227
+ } {
228
+ if (typeof body === 'string') {
229
+ const bytes = Buffer.byteLength(body, 'utf-8');
230
+ return { Body: body, getBytesUploaded: () => bytes };
231
+ }
232
+ if (body instanceof Uint8Array) {
233
+ return { Body: body, getBytesUploaded: () => body.byteLength };
234
+ }
235
+ if (body instanceof ArrayBuffer) {
236
+ const buf = Buffer.from(body);
237
+ return { Body: buf, getBytesUploaded: () => buf.byteLength };
238
+ }
239
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
240
+ // Convert the Blob to a Node stream, counting bytes as it flows.
241
+ const webStream = body.stream() as unknown as NodeWebReadableStream<Uint8Array>;
242
+ const nodeStream = Readable.fromWeb(webStream);
243
+ const { stream, getBytes } = countingPassthrough(nodeStream);
244
+ return { Body: stream, getBytesUploaded: getBytes };
245
+ }
246
+ if (isWebReadableStream(body)) {
247
+ const nodeStream = Readable.fromWeb(body as unknown as NodeWebReadableStream<Uint8Array>);
248
+ const { stream, getBytes } = countingPassthrough(nodeStream);
249
+ return { Body: stream, getBytesUploaded: getBytes };
250
+ }
251
+ // Fallthrough: pass through whatever it is. Should be unreachable
252
+ // given the `S3Body` union, but keeps the function total.
253
+ return {
254
+ Body: body as unknown as Uint8Array,
255
+ getBytesUploaded: () => 0,
256
+ };
257
+ }
258
+
259
+ function isWebReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
260
+ return (
261
+ typeof value === 'object' &&
262
+ value !== null &&
263
+ typeof (value as { getReader?: unknown }).getReader === 'function'
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Wrap a Node `Readable` in a passthrough that counts bytes flowing
269
+ * through it. The returned `getBytes()` callback returns the running
270
+ * tally; call it after the consumer has finished reading.
271
+ */
272
+ function countingPassthrough(source: Readable): {
273
+ stream: Readable;
274
+ getBytes(): number;
275
+ } {
276
+ let count = 0;
277
+ const counter = new Transform({
278
+ transform(chunk, _enc, cb) {
279
+ count += chunk.length;
280
+ cb(null, chunk);
281
+ },
282
+ });
283
+ source.pipe(counter);
284
+ source.on('error', (err) => counter.destroy(err));
285
+ return { stream: counter, getBytes: () => count };
286
+ }
287
+
288
+ /**
289
+ * Read an SDK `GetObjectCommand` response body fully into an
290
+ * `ArrayBuffer`. The SDK returns `unknown` for the body shape because
291
+ * it varies by runtime; in Node it's an `IncomingMessage`-style
292
+ * `Readable`.
293
+ */
294
+ async function readBodyAsArrayBuffer(body: unknown): Promise<ArrayBuffer> {
295
+ if (!body) return new ArrayBuffer(0);
296
+ if (body instanceof Uint8Array) {
297
+ // Some shapes return a Uint8Array directly. Copy into a fresh
298
+ // ArrayBuffer so the return type is the strict ArrayBuffer rather
299
+ // than ArrayBuffer | SharedArrayBuffer (which `.buffer` may be).
300
+ const out = new Uint8Array(body.byteLength);
301
+ out.set(body);
302
+ return out.buffer;
303
+ }
304
+ if (isWebReadableStream(body)) {
305
+ const reader = body.getReader();
306
+ const chunks: Uint8Array[] = [];
307
+ while (true) {
308
+ const { value, done } = await reader.read();
309
+ if (done) break;
310
+ if (value) chunks.push(value);
311
+ }
312
+ return concatUint8Arrays(chunks).buffer;
313
+ }
314
+ // Otherwise treat as a Node Readable.
315
+ const chunks: Buffer[] = [];
316
+ for await (const chunk of body as Readable) {
317
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
318
+ }
319
+ const merged = Buffer.concat(chunks);
320
+ // Copy into a tightly-sized ArrayBuffer to avoid sharing the underlying
321
+ // pool buffer with other Buffer instances.
322
+ const out = new Uint8Array(merged.byteLength);
323
+ out.set(merged);
324
+ return out.buffer;
325
+ }
326
+
327
+ /**
328
+ * Convert an SDK response body into a Web `ReadableStream<Uint8Array>`.
329
+ * Returns an empty stream when the body is missing.
330
+ */
331
+ function bodyToWebStream(body: unknown): ReadableStream<Uint8Array> {
332
+ if (!body) {
333
+ return new ReadableStream<Uint8Array>({
334
+ start(controller) {
335
+ controller.close();
336
+ },
337
+ });
338
+ }
339
+ if (isWebReadableStream(body)) return body;
340
+ if (body instanceof Uint8Array) {
341
+ return new ReadableStream<Uint8Array>({
342
+ start(controller) {
343
+ controller.enqueue(body);
344
+ controller.close();
345
+ },
346
+ });
347
+ }
348
+ // Assume Node Readable.
349
+ return Readable.toWeb(body as Readable) as unknown as ReadableStream<Uint8Array>;
350
+ }
351
+
352
+ function concatUint8Arrays(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
353
+ let total = 0;
354
+ for (const p of parts) total += p.byteLength;
355
+ // Allocate against a concrete ArrayBuffer (not SharedArrayBuffer) so
356
+ // the resulting `.buffer` is typed as ArrayBuffer.
357
+ const out = new Uint8Array(new ArrayBuffer(total));
358
+ let offset = 0;
359
+ for (const p of parts) {
360
+ out.set(p, offset);
361
+ offset += p.byteLength;
362
+ }
363
+ return out;
364
+ }
365
+
366
+ /**
367
+ * Pull the leading hostname label off a virtual-hosted-style endpoint
368
+ * URL. Falls back to `'bucket'` if parsing fails — which is fine
369
+ * because the endpoint override controls actual routing; the SDK only
370
+ * uses `Bucket` to construct path-style URLs (which we never do).
371
+ */
372
+ function extractBucketLabel(endpoint: string): string {
373
+ try {
374
+ const url = new URL(endpoint);
375
+ const firstLabel = url.hostname.split('.')[0];
376
+ return firstLabel || 'bucket';
377
+ } catch {
378
+ return 'bucket';
379
+ }
380
+ }