@specific.dev/spectest 0.9.0 → 0.11.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/src/recorder.ts CHANGED
@@ -19,6 +19,8 @@ export type TestEvent =
19
19
  | KubeEvent
20
20
  | BrowserEvent
21
21
  | DbEvent
22
+ | RedisEvent
23
+ | S3Event
22
24
  | TerminalEvent
23
25
  | TerminalStepEvent
24
26
  | WaitEvent
@@ -124,6 +126,67 @@ export interface DbEvent extends BaseEvent {
124
126
  error?: string;
125
127
  }
126
128
 
129
+ /**
130
+ * One Redis/Valkey command issued through the instrumented {@link
131
+ * "../redis".RedisClient} (a drop-in for `Bun.RedisClient` / `Bun.redis`).
132
+ *
133
+ * The return value is `wrap()`ped against this event's seq, so a later
134
+ * `expect(...)` that drills into it nests under this step in the timeline
135
+ * (same mechanism as http/db).
136
+ */
137
+ export interface RedisEvent extends BaseEvent {
138
+ kind: "redis";
139
+ /** Label for the connection — the URL host (often the service name). */
140
+ service: string;
141
+ /** Command name, upper-cased (`GET`, `SET`, `HGETALL`, `PUBLISH`, …). */
142
+ command: string;
143
+ /** First argument — almost always the key the command targets. Surfaced
144
+ * separately so the UI can read `GET session:42` at a glance. */
145
+ key?: string;
146
+ /** Remaining arguments (best-effort JSON-safe; large/binary stringified).
147
+ * Omitted when there are none. */
148
+ args?: unknown[];
149
+ /** Reply, best-effort JSON-safe and capped. Omitted when the command threw
150
+ * or returned `null`/`undefined`. */
151
+ result?: unknown;
152
+ resultTruncated?: boolean;
153
+ durationMs: number;
154
+ /** Set if the command threw. */
155
+ error?: string;
156
+ }
157
+
158
+ /**
159
+ * One object-storage operation through the instrumented {@link
160
+ * "../s3".S3Client} (a drop-in for `Bun.S3Client` / `Bun.s3`), covering both
161
+ * client-level calls (`write`/`delete`/`exists`/`list`/`presign`/`stat`) and
162
+ * the `S3File` handle `.file(path)` returns (`text`/`json`/`write`/…).
163
+ *
164
+ * The return value is `wrap()`ped against this event's seq, so `expect(...)`
165
+ * drilling into it nests under this step (same mechanism as http/db).
166
+ */
167
+ export interface S3Event extends BaseEvent {
168
+ kind: "s3";
169
+ /** Operation: `write` | `read` | `delete` | `exists` | `list` |
170
+ * `presign` | `stat`. */
171
+ op: string;
172
+ /** Bucket, when the client/handle knows it. */
173
+ bucket?: string;
174
+ /** Object key (path) the op targeted. Omitted for bucket-wide `list`. */
175
+ key?: string;
176
+ /** Bytes written or read, when known. */
177
+ size?: number;
178
+ /** Content type, when known (read/write/stat). */
179
+ contentType?: string;
180
+ /** Small text preview of the body (read/write of text), capped. */
181
+ preview?: string;
182
+ previewTruncated?: boolean;
183
+ /** For `list`: number of keys returned. */
184
+ count?: number;
185
+ durationMs: number;
186
+ /** Set if the op threw. */
187
+ error?: string;
188
+ }
189
+
127
190
  export interface HttpEvent extends BaseEvent {
128
191
  kind: "http";
129
192
  method: string;
@@ -556,6 +619,20 @@ export function recordDb(
556
619
  return active() ? current!.push({ kind: "db", ...ev }, reservation) : undefined;
557
620
  }
558
621
 
622
+ export function recordRedis(
623
+ ev: Omit<RedisEvent, "seq" | "tOffsetMs" | "kind">,
624
+ reservation?: EventReservation,
625
+ ): number | undefined {
626
+ return active() ? current!.push({ kind: "redis", ...ev }, reservation) : undefined;
627
+ }
628
+
629
+ export function recordS3(
630
+ ev: Omit<S3Event, "seq" | "tOffsetMs" | "kind">,
631
+ reservation?: EventReservation,
632
+ ): number | undefined {
633
+ return active() ? current!.push({ kind: "s3", ...ev }, reservation) : undefined;
634
+ }
635
+
559
636
  export function recordTerminal(
560
637
  ev: Omit<TerminalEvent, "seq" | "tOffsetMs" | "kind">,
561
638
  reservation?: EventReservation,
package/src/redis.ts ADDED
@@ -0,0 +1,202 @@
1
+ // `RedisClient` — a drop-in for `Bun.RedisClient` (`Bun.redis`) that records
2
+ // every command on the test event log and returns its reply inspect-wrapped,
3
+ // so `expect(await client.get("k"))` links the assertion back to the command in
4
+ // the timeline (same provenance mechanism as the wrapped `fetch` / `SQL`). Swap
5
+ // `new Bun.RedisClient(url)` → `new RedisClient(url)` and nothing else changes
6
+ // at the call site; replies come back `Wrapped<T>` (that wrapper is what carries
7
+ // provenance), so `.unwrap()` before a `===` or before feeding a value onward.
8
+ //
9
+ // Reach for it in service `helpers` when you stand up a redis/valkey yourself,
10
+ // or directly in a test against a connection string a fake handed back.
11
+
12
+ import { recordRedis, reserveEvent, safeSerialize } from "./recorder.js";
13
+ import { wrap } from "./inspect.js";
14
+ import type { Wrapped } from "./inspect.js";
15
+
16
+ /**
17
+ * Structural type for the parts of `Bun.RedisClient` we use, declared locally
18
+ * so user projects don't need `@types/bun`. The real implementation comes from
19
+ * Bun at runtime via `globalThis.Bun.RedisClient`. Commands resolve to their
20
+ * reply; lifecycle methods (`connect`/`close`/`subscribe`/…) are passed through
21
+ * un-instrumented.
22
+ */
23
+ interface RawRedisClient {
24
+ send(command: string, args: string[]): Promise<unknown>;
25
+ close?(): void;
26
+ [method: string]: unknown;
27
+ }
28
+
29
+ /** The instrumented Redis surface — same shape as `Bun.RedisClient`, but every
30
+ * command resolves to a {@link Wrapped} reply recorded as a `redis` event.
31
+ * Indexable so every Bun command method is reachable without re-declaring the
32
+ * whole (large) surface; the common ones are typed for ergonomics. */
33
+ export interface RedisClientLike {
34
+ get(key: string): Promise<Wrapped<string | null>>;
35
+ set(key: string, value: string): Promise<Wrapped<string>>;
36
+ del(...keys: string[]): Promise<Wrapped<number>>;
37
+ exists(...keys: string[]): Promise<Wrapped<number>>;
38
+ incr(key: string): Promise<Wrapped<number>>;
39
+ hgetall(key: string): Promise<Wrapped<Record<string, string>>>;
40
+ /** Run an arbitrary command — `client.send("SET", ["k", "v"])`. */
41
+ send(command: string, args: string[]): Promise<Wrapped<unknown>>;
42
+ close(): void;
43
+ [method: string]: (...args: any[]) => any;
44
+ }
45
+
46
+ /** Options accepted by the {@link RedisClient} constructor beyond Bun's own. */
47
+ export interface RedisOptions {
48
+ /** Label shown on each recorded `redis` event (the timeline step's tag).
49
+ * Defaults to the connection URL's host (usually the service name). */
50
+ label?: string;
51
+ }
52
+
53
+ interface BunGlobal {
54
+ RedisClient: new (url?: string, options?: unknown) => RawRedisClient;
55
+ }
56
+
57
+ interface RedisConstructor {
58
+ new (url?: string, opts?: RedisOptions): RedisClientLike;
59
+ (url?: string, opts?: RedisOptions): RedisClientLike;
60
+ }
61
+
62
+ // Long-lived / connection-management methods that don't map to a single
63
+ // request→reply step — passed straight through, never recorded.
64
+ const REDIS_PASSTHROUGH = new Set([
65
+ "connect",
66
+ "close",
67
+ "disconnect",
68
+ "duplicate",
69
+ "subscribe",
70
+ "unsubscribe",
71
+ "psubscribe",
72
+ "punsubscribe",
73
+ "ssubscribe",
74
+ "sunsubscribe",
75
+ "onclose",
76
+ "ref",
77
+ "unref",
78
+ ]);
79
+
80
+ const MAX_RESULT_BYTES = 2048;
81
+
82
+ /**
83
+ * Open an instrumented Redis client against `url` (default: Bun's own
84
+ * `REDIS_URL`/`VALKEY_URL`). Usable with or without `new`. Requires the Bun
85
+ * runtime — it runs inside the spectest daemon.
86
+ */
87
+ export const RedisClient = function RedisClient(
88
+ url?: string,
89
+ opts?: RedisOptions,
90
+ ): RedisClientLike {
91
+ const bun = (globalThis as unknown as { Bun?: BunGlobal }).Bun;
92
+ if (!bun?.RedisClient) {
93
+ throw new Error(
94
+ "RedisClient requires the Bun runtime (Bun >= 1.2.9) — it runs inside the spectest daemon.",
95
+ );
96
+ }
97
+ const raw = url !== undefined ? new bun.RedisClient(url, opts) : new bun.RedisClient();
98
+ return instrumentRedis(raw, opts?.label ?? hostLabel(url));
99
+ } as unknown as RedisConstructor;
100
+
101
+ function hostLabel(url?: string): string {
102
+ if (!url) return "redis";
103
+ try {
104
+ return new URL(url).hostname || "redis";
105
+ } catch {
106
+ return "redis";
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Proxy a `Bun.RedisClient` so each command method (and `send(...)`) emits a
112
+ * `redis` event when its promise settles and resolves to a {@link Wrapped}
113
+ * reply. Lifecycle methods and non-function properties pass through.
114
+ *
115
+ * Exported so a client built another way can opt into the same instrumentation.
116
+ */
117
+ export function instrumentRedis(raw: RawRedisClient, label: string): RedisClientLike {
118
+ const run = (
119
+ command: string,
120
+ key: string | undefined,
121
+ extra: unknown[],
122
+ invoke: () => unknown,
123
+ ): Promise<unknown> => {
124
+ const started = Date.now();
125
+ const resv = reserveEvent();
126
+ return Promise.resolve(invoke()).then(
127
+ (value) => {
128
+ const { preview, truncated } = capValue(value);
129
+ const seq = recordRedis(
130
+ {
131
+ service: label,
132
+ command,
133
+ key,
134
+ args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
135
+ result: preview,
136
+ resultTruncated: truncated,
137
+ durationMs: Date.now() - started,
138
+ },
139
+ resv,
140
+ );
141
+ return wrap(value, seq);
142
+ },
143
+ (err) => {
144
+ const e = err as Error;
145
+ recordRedis(
146
+ {
147
+ service: label,
148
+ command,
149
+ key,
150
+ args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
151
+ durationMs: Date.now() - started,
152
+ error: e?.message ?? String(err),
153
+ },
154
+ resv,
155
+ );
156
+ throw err;
157
+ },
158
+ );
159
+ };
160
+
161
+ const handler: ProxyHandler<RawRedisClient> = {
162
+ get(target, prop, receiver) {
163
+ if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
164
+ const v = Reflect.get(target, prop, receiver);
165
+ if (typeof v !== "function") return v;
166
+ const fn = v as (...a: unknown[]) => unknown;
167
+ if (REDIS_PASSTHROUGH.has(prop)) return fn.bind(target);
168
+ // `send(command, args)` carries the real command in its first arg.
169
+ if (prop === "send") {
170
+ return (command: string, args: string[] = []) =>
171
+ run(String(command).toUpperCase(), args[0], args.slice(1), () =>
172
+ fn.call(target, command, args),
173
+ );
174
+ }
175
+ return (...args: unknown[]) =>
176
+ run(
177
+ prop.toUpperCase(),
178
+ typeof args[0] === "string" ? args[0] : undefined,
179
+ args.slice(1),
180
+ () => fn.apply(target, args),
181
+ );
182
+ },
183
+ };
184
+ return new Proxy(raw, handler) as unknown as RedisClientLike;
185
+ }
186
+
187
+ /** Best-effort JSON-safe preview of a reply, capped at {@link MAX_RESULT_BYTES}. */
188
+ function capValue(value: unknown): { preview?: unknown; truncated?: boolean } {
189
+ if (value === null || value === undefined) return {};
190
+ const safe = safeSerialize(value);
191
+ const json = (() => {
192
+ try {
193
+ return JSON.stringify(safe);
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ })();
198
+ if (json !== undefined && json.length > MAX_RESULT_BYTES) {
199
+ return { preview: `${json.slice(0, MAX_RESULT_BYTES)}…`, truncated: true };
200
+ }
201
+ return { preview: safe };
202
+ }
package/src/s3.ts ADDED
@@ -0,0 +1,333 @@
1
+ // `S3Client` — a drop-in for `Bun.S3Client` (`Bun.s3`) that records every
2
+ // object-storage operation on the test event log and returns its result
3
+ // inspect-wrapped, so `expect(await client.file("k").json())` links the
4
+ // assertion back to the read in the timeline (same provenance mechanism as the
5
+ // wrapped `fetch` / `SQL` / `RedisClient`). Swap `new Bun.S3Client(opts)` →
6
+ // `new S3Client(opts)` and nothing else changes at the call site.
7
+ //
8
+ // Instrumentation covers both the client-level ops (`write`/`delete`/`exists`/
9
+ // `list`/`presign`/`stat`/`size`) and the lazy `S3File` handle that
10
+ // `.file(path)` returns (`text`/`json`/`arrayBuffer`/`bytes`/`write`/…). Reach
11
+ // for it whenever you stand up S3-compatible storage (MinIO, real S3, R2) in a
12
+ // service `helpers` factory or a test.
13
+
14
+ import { recordS3, reserveEvent } from "./recorder.js";
15
+ import { wrap } from "./inspect.js";
16
+ import type { Wrapped } from "./inspect.js";
17
+
18
+ /** Structural types for the parts of `Bun.S3Client` we use, declared locally so
19
+ * user projects don't need `@types/bun`. The real implementation comes from
20
+ * Bun at runtime via `globalThis.Bun.S3Client`. */
21
+ interface RawS3File {
22
+ name?: string;
23
+ bucket?: string;
24
+ size?: number;
25
+ type?: string;
26
+ text(): Promise<string>;
27
+ json<T = unknown>(): Promise<T>;
28
+ arrayBuffer(): Promise<ArrayBuffer>;
29
+ bytes(): Promise<Uint8Array>;
30
+ write(data: unknown, options?: unknown): Promise<number>;
31
+ exists(): Promise<boolean>;
32
+ delete(): Promise<void>;
33
+ unlink?(): Promise<void>;
34
+ stat(): Promise<unknown>;
35
+ presign(options?: unknown): string;
36
+ [m: string]: unknown;
37
+ }
38
+
39
+ interface RawS3Client {
40
+ file(path: string, options?: unknown): RawS3File;
41
+ write(path: string, data: unknown, options?: unknown): Promise<number>;
42
+ delete(path: string, options?: unknown): Promise<void>;
43
+ unlink?(path: string, options?: unknown): Promise<void>;
44
+ exists(path: string, options?: unknown): Promise<boolean>;
45
+ size?(path: string, options?: unknown): Promise<number>;
46
+ stat(path: string, options?: unknown): Promise<unknown>;
47
+ presign(path: string, options?: unknown): string;
48
+ list(input?: unknown, options?: unknown): Promise<unknown>;
49
+ [m: string]: unknown;
50
+ }
51
+
52
+ /** A lazy S3 object handle (Bun's `S3File`), wrapped so each read/write records
53
+ * an `s3` event and returns a {@link Wrapped} result. */
54
+ export interface S3File {
55
+ readonly name?: string;
56
+ readonly bucket?: string;
57
+ readonly size?: number;
58
+ text(): Promise<Wrapped<string>>;
59
+ json<T = unknown>(): Promise<Wrapped<T>>;
60
+ arrayBuffer(): Promise<Wrapped<ArrayBuffer>>;
61
+ bytes(): Promise<Wrapped<Uint8Array>>;
62
+ write(data: unknown, options?: unknown): Promise<Wrapped<number>>;
63
+ exists(): Promise<Wrapped<boolean>>;
64
+ delete(): Promise<Wrapped<void>>;
65
+ stat(): Promise<Wrapped<unknown>>;
66
+ presign(options?: unknown): string;
67
+ }
68
+
69
+ /** The instrumented S3 surface — same shape as `Bun.S3Client`, but every op
70
+ * records an `s3` event and resolves to a {@link Wrapped} result. */
71
+ export interface S3ClientLike {
72
+ file(path: string, options?: unknown): S3File;
73
+ write(path: string, data: unknown, options?: unknown): Promise<Wrapped<number>>;
74
+ delete(path: string, options?: unknown): Promise<Wrapped<void>>;
75
+ exists(path: string, options?: unknown): Promise<Wrapped<boolean>>;
76
+ stat(path: string, options?: unknown): Promise<Wrapped<unknown>>;
77
+ list(input?: unknown, options?: unknown): Promise<Wrapped<unknown>>;
78
+ presign(path: string, options?: unknown): string;
79
+ [m: string]: (...args: any[]) => any;
80
+ }
81
+
82
+ /** Options accepted by the {@link S3Client} constructor — Bun's own plus a
83
+ * recording `label`. Passed through to `Bun.S3Client` verbatim. */
84
+ export interface S3Options {
85
+ accessKeyId?: string;
86
+ secretAccessKey?: string;
87
+ sessionToken?: string;
88
+ bucket?: string;
89
+ region?: string;
90
+ endpoint?: string;
91
+ [opt: string]: unknown;
92
+ }
93
+
94
+ interface BunGlobal {
95
+ S3Client: new (options?: S3Options) => RawS3Client;
96
+ }
97
+
98
+ interface S3Constructor {
99
+ new (options?: S3Options): S3ClientLike;
100
+ (options?: S3Options): S3ClientLike;
101
+ }
102
+
103
+ const MAX_PREVIEW = 512;
104
+
105
+ /**
106
+ * Open an instrumented S3 client. Usable with or without `new`. Requires the
107
+ * Bun runtime — it runs inside the spectest daemon.
108
+ */
109
+ export const S3Client = function S3Client(options?: S3Options): S3ClientLike {
110
+ const bun = (globalThis as unknown as { Bun?: BunGlobal }).Bun;
111
+ if (!bun?.S3Client) {
112
+ throw new Error(
113
+ "S3Client requires the Bun runtime (Bun >= 1.1) — it runs inside the spectest daemon.",
114
+ );
115
+ }
116
+ return instrumentS3(new bun.S3Client(options), options?.bucket);
117
+ } as unknown as S3Constructor;
118
+
119
+ /** Record an awaitable S3 op, wrapping its settled result for provenance. */
120
+ function runS3<T>(
121
+ base: { op: string; bucket?: string; key?: string },
122
+ invoke: () => PromiseLike<T>,
123
+ describe?: (value: T) => Partial<{
124
+ size: number;
125
+ contentType: string;
126
+ preview: string;
127
+ previewTruncated: boolean;
128
+ count: number;
129
+ }>,
130
+ ): Promise<T> {
131
+ const started = Date.now();
132
+ const resv = reserveEvent();
133
+ return Promise.resolve(invoke()).then(
134
+ (value) => {
135
+ const seq = recordS3(
136
+ { ...base, ...(describe?.(value) ?? {}), durationMs: Date.now() - started },
137
+ resv,
138
+ );
139
+ return wrap(value, seq) as T;
140
+ },
141
+ (err) => {
142
+ const e = err as Error;
143
+ recordS3(
144
+ { ...base, durationMs: Date.now() - started, error: e?.message ?? String(err) },
145
+ resv,
146
+ );
147
+ throw err;
148
+ },
149
+ );
150
+ }
151
+
152
+ /** Record a synchronous op (`presign`) — no result wrapping (returns a URL). */
153
+ function runS3Sync<T>(base: { op: string; bucket?: string; key?: string }, invoke: () => T): T {
154
+ const started = Date.now();
155
+ try {
156
+ const value = invoke();
157
+ recordS3({ ...base, durationMs: Date.now() - started }, undefined);
158
+ return value;
159
+ } catch (err) {
160
+ const e = err as Error;
161
+ recordS3(
162
+ { ...base, durationMs: Date.now() - started, error: e?.message ?? String(err) },
163
+ undefined,
164
+ );
165
+ throw err;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Proxy a `Bun.S3Client` so each op records an `s3` event. `file(path)` returns
171
+ * a wrapped {@link S3File}; unknown methods pass through.
172
+ *
173
+ * Exported so a client built another way can opt into the same instrumentation.
174
+ */
175
+ export function instrumentS3(raw: RawS3Client, bucket?: string): S3ClientLike {
176
+ const handler: ProxyHandler<RawS3Client> = {
177
+ get(target, prop, receiver) {
178
+ if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
179
+ switch (prop) {
180
+ case "file":
181
+ return (path: string, options?: unknown) =>
182
+ instrumentS3File(target.file(path, options), path, bucket);
183
+ case "write":
184
+ return (path: string, data: unknown, options?: unknown) =>
185
+ runS3({ op: "write", bucket, key: path }, () => target.write(path, data, options), () =>
186
+ describeData(data),
187
+ );
188
+ case "delete":
189
+ case "unlink":
190
+ return (path: string, options?: unknown) =>
191
+ runS3({ op: "delete", bucket, key: path }, () => target.delete(path, options));
192
+ case "exists":
193
+ return (path: string, options?: unknown) =>
194
+ runS3({ op: "exists", bucket, key: path }, () => target.exists(path, options));
195
+ case "size":
196
+ return (path: string, options?: unknown) =>
197
+ runS3({ op: "stat", bucket, key: path }, () => target.size!(path, options), (n) => ({
198
+ size: typeof n === "number" ? n : undefined,
199
+ }));
200
+ case "stat":
201
+ return (path: string, options?: unknown) =>
202
+ runS3({ op: "stat", bucket, key: path }, () => target.stat(path, options), describeStat);
203
+ case "list":
204
+ return (input?: unknown, options?: unknown) =>
205
+ runS3({ op: "list", bucket }, () => target.list(input, options), (r) => ({
206
+ count: listCount(r),
207
+ }));
208
+ case "presign":
209
+ return (path: string, options?: unknown) =>
210
+ runS3Sync({ op: "presign", bucket, key: path }, () => target.presign(path, options));
211
+ default: {
212
+ const v = Reflect.get(target, prop, receiver);
213
+ return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
214
+ }
215
+ }
216
+ },
217
+ };
218
+ return new Proxy(raw, handler) as unknown as S3ClientLike;
219
+ }
220
+
221
+ function instrumentS3File(raw: RawS3File, key: string, bucket?: string): S3File {
222
+ const b = raw.bucket ?? bucket;
223
+ const handler: ProxyHandler<RawS3File> = {
224
+ get(target, prop, receiver) {
225
+ if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
226
+ switch (prop) {
227
+ case "text":
228
+ return () => runS3({ op: "read", bucket: b, key }, () => target.text(), describeData);
229
+ case "json":
230
+ return () => runS3({ op: "read", bucket: b, key }, () => target.json(), describeData);
231
+ case "arrayBuffer":
232
+ return () =>
233
+ runS3({ op: "read", bucket: b, key }, () => target.arrayBuffer(), describeData);
234
+ case "bytes":
235
+ return () => runS3({ op: "read", bucket: b, key }, () => target.bytes(), describeData);
236
+ case "write":
237
+ return (data: unknown, options?: unknown) =>
238
+ runS3({ op: "write", bucket: b, key }, () => target.write(data, options), () =>
239
+ describeData(data),
240
+ );
241
+ case "exists":
242
+ return () => runS3({ op: "exists", bucket: b, key }, () => target.exists());
243
+ case "delete":
244
+ case "unlink":
245
+ return () => runS3({ op: "delete", bucket: b, key }, () => target.delete());
246
+ case "stat":
247
+ return () => runS3({ op: "stat", bucket: b, key }, () => target.stat(), describeStat);
248
+ case "presign":
249
+ return (options?: unknown) =>
250
+ runS3Sync({ op: "presign", bucket: b, key }, () => target.presign(options));
251
+ default: {
252
+ const v = Reflect.get(target, prop, receiver);
253
+ return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
254
+ }
255
+ }
256
+ },
257
+ };
258
+ return new Proxy(raw, handler) as unknown as S3File;
259
+ }
260
+
261
+ /** Byte size + small text preview of a written/read body. */
262
+ function describeData(value: unknown): Partial<{
263
+ size: number;
264
+ contentType: string;
265
+ preview: string;
266
+ previewTruncated: boolean;
267
+ }> {
268
+ if (typeof value === "string") {
269
+ const truncated = value.length > MAX_PREVIEW;
270
+ return {
271
+ size: byteLength(value),
272
+ preview: truncated ? value.slice(0, MAX_PREVIEW) : value,
273
+ previewTruncated: truncated || undefined,
274
+ };
275
+ }
276
+ if (value && typeof value === "object" && !isBinary(value)) {
277
+ // A parsed JSON body (from `.json()`): preview a capped serialization.
278
+ try {
279
+ const json = JSON.stringify(value);
280
+ const truncated = json.length > MAX_PREVIEW;
281
+ return {
282
+ contentType: "application/json",
283
+ preview: truncated ? `${json.slice(0, MAX_PREVIEW)}…` : json,
284
+ previewTruncated: truncated || undefined,
285
+ };
286
+ } catch {
287
+ /* fall through to size-only */
288
+ }
289
+ }
290
+ const size = byteLength(value);
291
+ return size !== undefined ? { size } : {};
292
+ }
293
+
294
+ function describeStat(stat: unknown): Partial<{ size: number; contentType: string }> {
295
+ if (stat && typeof stat === "object") {
296
+ const s = stat as { size?: unknown; type?: unknown; contentType?: unknown };
297
+ return {
298
+ size: typeof s.size === "number" ? s.size : undefined,
299
+ contentType:
300
+ typeof s.type === "string"
301
+ ? s.type
302
+ : typeof s.contentType === "string"
303
+ ? s.contentType
304
+ : undefined,
305
+ };
306
+ }
307
+ return {};
308
+ }
309
+
310
+ function listCount(result: unknown): number | undefined {
311
+ if (result && typeof result === "object") {
312
+ const r = result as { contents?: unknown[]; keyCount?: unknown };
313
+ if (Array.isArray(r.contents)) return r.contents.length;
314
+ if (typeof r.keyCount === "number") return r.keyCount;
315
+ }
316
+ return undefined;
317
+ }
318
+
319
+ function isBinary(value: unknown): boolean {
320
+ return (
321
+ value instanceof ArrayBuffer ||
322
+ ArrayBuffer.isView(value) ||
323
+ (typeof Blob !== "undefined" && value instanceof Blob)
324
+ );
325
+ }
326
+
327
+ function byteLength(value: unknown): number | undefined {
328
+ if (typeof value === "string") return Buffer.byteLength(value);
329
+ if (value instanceof ArrayBuffer) return value.byteLength;
330
+ if (ArrayBuffer.isView(value)) return value.byteLength;
331
+ if (typeof Blob !== "undefined" && value instanceof Blob) return value.size;
332
+ return undefined;
333
+ }