@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/package.json +1 -1
- package/src/components/index.ts +5 -2
- package/src/components/postgres.ts +34 -210
- package/src/daemon.ts +22 -0
- package/src/index.ts +14 -92
- package/src/inspect.ts +61 -4
- package/src/recorder.ts +77 -0
- package/src/redis.ts +202 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +223 -0
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
|
+
}
|