@specific.dev/spectest 0.26.0 → 0.28.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/dist/aws-sigv4.d.ts +42 -0
- package/dist/aws-sigv4.js +166 -0
- package/dist/browser.d.ts +314 -0
- package/dist/browser.js +1320 -0
- package/dist/components/email.d.ts +135 -0
- package/dist/components/email.js +271 -0
- package/dist/components/expo.d.ts +69 -0
- package/dist/components/expo.js +125 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.js +18 -0
- package/dist/components/k3s.d.ts +172 -0
- package/dist/components/k3s.js +1124 -0
- package/dist/components/postgres.d.ts +93 -0
- package/dist/components/postgres.js +58 -0
- package/dist/components/replayFake.d.ts +169 -0
- package/dist/components/replayFake.js +738 -0
- package/dist/components/s3.d.ts +99 -0
- package/dist/components/s3.js +81 -0
- package/dist/components/supabase.d.ts +197 -0
- package/dist/components/supabase.js +1003 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +4611 -0
- package/dist/ids.d.ts +2 -0
- package/{src/ids.ts → dist/ids.js} +46 -50
- package/dist/index.d.ts +1328 -0
- package/dist/index.js +769 -0
- package/dist/ingress.d.ts +114 -0
- package/dist/ingress.js +210 -0
- package/dist/inspect.d.ts +228 -0
- package/dist/inspect.js +429 -0
- package/dist/locator.d.ts +260 -0
- package/dist/locator.js +293 -0
- package/dist/mobile.d.ts +71 -0
- package/dist/mobile.js +65 -0
- package/dist/record-secrets.d.ts +9 -0
- package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
- package/dist/recorder.d.ts +527 -0
- package/dist/recorder.js +219 -0
- package/dist/redis.d.ts +54 -0
- package/dist/redis.js +126 -0
- package/dist/replay-bundle.d.ts +38 -0
- package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
- package/dist/resolver.d.ts +1 -0
- package/dist/resolver.js +309 -0
- package/dist/s3.d.ts +89 -0
- package/dist/s3.js +198 -0
- package/dist/sql.d.ts +74 -0
- package/dist/sql.js +151 -0
- package/dist/terminal.d.ts +161 -0
- package/dist/terminal.js +538 -0
- package/package.json +24 -9
- package/src/browser.ts +0 -1819
- package/src/components/email.ts +0 -398
- package/src/components/expo.ts +0 -167
- package/src/components/index.ts +0 -63
- package/src/components/k3s.ts +0 -1312
- package/src/components/postgres.ts +0 -105
- package/src/components/replayFake.ts +0 -848
- package/src/components/s3.ts +0 -132
- package/src/components/supabase.ts +0 -1299
- package/src/daemon.ts +0 -4969
- package/src/index.ts +0 -2350
- package/src/ingress.ts +0 -288
- package/src/inspect.ts +0 -673
- package/src/locator.ts +0 -594
- package/src/mobile.ts +0 -133
- package/src/recorder.ts +0 -817
- package/src/redis.ts +0 -202
- package/src/resolver.ts +0 -351
- package/src/s3.ts +0 -333
- package/src/sql.ts +0 -243
- package/src/terminal.ts +0 -740
- package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
- package/src/vendor/rrweb-record.min.js +0 -5061
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// spectest-resolver: a tiny DNS server that mirrors Docker's embedded
|
|
2
|
+
// DNS, but from the VM host instead of from inside a container.
|
|
3
|
+
//
|
|
4
|
+
// Single-label lookups (no dots, e.g. `api`) hit the Docker socket by
|
|
5
|
+
// container name. Multi-label lookups (e.g. `api.stripe.com`) scan
|
|
6
|
+
// containers on `spectest-net` for one whose network aliases include the
|
|
7
|
+
// queried name — set via service `hostnames` in env.ts. Anything we
|
|
8
|
+
// can't resolve from Docker is forwarded to an upstream DNS server
|
|
9
|
+
// (default 1.1.1.1). The daemon and any test code running on the VM
|
|
10
|
+
// host can do `fetch("http://api:8000")` or `fetch("http://api.stripe.com")`
|
|
11
|
+
// exactly like a container on the same bridge would.
|
|
12
|
+
//
|
|
13
|
+
// Designed to be tiny: one process, no caching beyond DNS TTL (5s), no
|
|
14
|
+
// docker event subscription — every query asks Docker fresh. The latency
|
|
15
|
+
// is ~1ms per query and the failure mode (Docker socket down) is rare
|
|
16
|
+
// enough that simplicity wins.
|
|
17
|
+
//
|
|
18
|
+
// Listens on two *specific* addresses (never 0.0.0.0 — see the bind logic
|
|
19
|
+
// at the bottom): 127.0.0.53, the nameserver the VM's /etc/resolv.conf
|
|
20
|
+
// points at (daemon, host-side test code, and dockerd's embedded DNS as
|
|
21
|
+
// its ExtServer all use it), and the spectest-net bridge gateway IP,
|
|
22
|
+
// which is how an in-cluster k3s pod reaches us (k3s CoreDNS is pointed
|
|
23
|
+
// there; see components/k3s.ts). Binding the specific gateway IP rather
|
|
24
|
+
// than 0.0.0.0 matters: a 0.0.0.0 socket replies to a 127.0.0.53 query
|
|
25
|
+
// from a kernel-chosen source (127.0.0.1), and glibc's resolver drops the
|
|
26
|
+
// answer because its source doesn't match the queried server.
|
|
27
|
+
import { createSocket } from "node:dgram";
|
|
28
|
+
import { request as httpRequest } from "node:http";
|
|
29
|
+
import { readFile, stat } from "node:fs/promises";
|
|
30
|
+
import * as dnsPacket from "dns-packet";
|
|
31
|
+
const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
|
|
32
|
+
const DOCKER_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock";
|
|
33
|
+
const UPSTREAM_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
34
|
+
const UPSTREAM_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
35
|
+
// Primary listen address: the loopback nameserver in the VM's
|
|
36
|
+
// /etc/resolv.conf. Always bound at startup.
|
|
37
|
+
const LISTEN_ADDR = process.env.SPECTEST_RESOLVER_ADDR ?? "127.0.0.53";
|
|
38
|
+
const LISTEN_PORT = Number(process.env.SPECTEST_RESOLVER_PORT ?? "53");
|
|
39
|
+
const TTL_SECONDS = Number(process.env.SPECTEST_RESOLVER_TTL ?? "5");
|
|
40
|
+
/** Path the spectest-daemon writes when it brings fakes up. */
|
|
41
|
+
const FAKES_REGISTRY_PATH = process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
|
|
42
|
+
function dockerGet(path) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const req = httpRequest({ socketPath: DOCKER_SOCKET, path, method: "GET" }, (res) => {
|
|
45
|
+
if (res.statusCode !== 200) {
|
|
46
|
+
res.resume();
|
|
47
|
+
resolve(null);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const chunks = [];
|
|
51
|
+
res.on("data", (c) => chunks.push(c));
|
|
52
|
+
res.on("end", () => {
|
|
53
|
+
try {
|
|
54
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
resolve(null);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
res.on("error", () => resolve(null));
|
|
61
|
+
});
|
|
62
|
+
req.on("error", () => resolve(null));
|
|
63
|
+
req.end();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Single-label lookup: ask Docker for the container with this exact name
|
|
68
|
+
* and read its IP on spectest-net. Cheap — one container fetch.
|
|
69
|
+
*/
|
|
70
|
+
async function dockerLookupByName(name) {
|
|
71
|
+
const body = (await dockerGet(`/containers/${encodeURIComponent(name)}/json`));
|
|
72
|
+
const ip = body?.NetworkSettings?.Networks?.[NETWORK_NAME]?.IPAddress;
|
|
73
|
+
return ip && ip.length > 0 ? ip : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Multi-label lookup: scan containers on spectest-net for one whose
|
|
77
|
+
* Aliases include `name`. Aliases are set with `--network-alias` at
|
|
78
|
+
* `docker run` time (see runContainer in daemon.ts).
|
|
79
|
+
*
|
|
80
|
+
* `/containers/json` returns `Aliases: null` in many Docker versions
|
|
81
|
+
* even when --network-alias was set, so we use it only to enumerate
|
|
82
|
+
* container IDs and then inspect each one — inspect reliably surfaces
|
|
83
|
+
* the alias list. The fan-out is bounded by service count (~handful),
|
|
84
|
+
* so the extra roundtrips are cheap.
|
|
85
|
+
*/
|
|
86
|
+
async function dockerLookupByAlias(name) {
|
|
87
|
+
const filters = encodeURIComponent(JSON.stringify({ network: [NETWORK_NAME] }));
|
|
88
|
+
const list = (await dockerGet(`/containers/json?filters=${filters}`));
|
|
89
|
+
if (!Array.isArray(list))
|
|
90
|
+
return null;
|
|
91
|
+
for (const c of list) {
|
|
92
|
+
if (!c.Id)
|
|
93
|
+
continue;
|
|
94
|
+
const info = (await dockerGet(`/containers/${c.Id}/json`));
|
|
95
|
+
const net = info?.NetworkSettings?.Networks?.[NETWORK_NAME];
|
|
96
|
+
if (!net)
|
|
97
|
+
continue;
|
|
98
|
+
if ((net.Aliases ?? []).includes(name) && net.IPAddress) {
|
|
99
|
+
return net.IPAddress;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
let registryCache = { mtimeMs: 0, hosts: {}, wildcards: [] };
|
|
105
|
+
async function refreshRegistry() {
|
|
106
|
+
try {
|
|
107
|
+
const st = await stat(FAKES_REGISTRY_PATH);
|
|
108
|
+
if (st.mtimeMs !== registryCache.mtimeMs) {
|
|
109
|
+
const raw = await readFile(FAKES_REGISTRY_PATH, "utf8");
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
registryCache = {
|
|
112
|
+
mtimeMs: st.mtimeMs,
|
|
113
|
+
hosts: parsed.hosts ?? {},
|
|
114
|
+
wildcards: parsed.wildcards ?? [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// File missing or unreadable — treat as empty.
|
|
120
|
+
registryCache = { mtimeMs: 0, hosts: {}, wildcards: [] };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Exact names-registry lookup. */
|
|
124
|
+
async function lookupFake(name) {
|
|
125
|
+
await refreshRegistry();
|
|
126
|
+
return registryCache.hosts[name] ?? null;
|
|
127
|
+
}
|
|
128
|
+
/** Wildcard suffix lookup — consulted only after an exact miss. The
|
|
129
|
+
* longest (most specific) matching suffix wins. */
|
|
130
|
+
async function lookupWildcard(name) {
|
|
131
|
+
await refreshRegistry();
|
|
132
|
+
let best = null;
|
|
133
|
+
for (const w of registryCache.wildcards) {
|
|
134
|
+
if (name.endsWith(w.suffix) && (!best || w.suffix.length > best.suffix.length)) {
|
|
135
|
+
best = w;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return best?.ip ?? null;
|
|
139
|
+
}
|
|
140
|
+
async function forwardUpstream(query) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const sock = createSocket("udp4");
|
|
143
|
+
let done = false;
|
|
144
|
+
const finish = (b) => {
|
|
145
|
+
if (done)
|
|
146
|
+
return;
|
|
147
|
+
done = true;
|
|
148
|
+
try {
|
|
149
|
+
sock.close();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// ignore
|
|
153
|
+
}
|
|
154
|
+
resolve(b);
|
|
155
|
+
};
|
|
156
|
+
sock.on("message", (msg) => finish(msg));
|
|
157
|
+
sock.on("error", () => finish(null));
|
|
158
|
+
sock.send(query, UPSTREAM_PORT, UPSTREAM_DNS, (err) => {
|
|
159
|
+
if (err)
|
|
160
|
+
finish(null);
|
|
161
|
+
});
|
|
162
|
+
setTimeout(() => finish(null), 3_000);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function emptyAnswer(query) {
|
|
166
|
+
return dnsPacket.encode({
|
|
167
|
+
type: "response",
|
|
168
|
+
id: query.id,
|
|
169
|
+
flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
|
|
170
|
+
questions: query.questions,
|
|
171
|
+
answers: [],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function aAnswer(query, name, ip) {
|
|
175
|
+
return dnsPacket.encode({
|
|
176
|
+
type: "response",
|
|
177
|
+
id: query.id,
|
|
178
|
+
flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
|
|
179
|
+
questions: query.questions,
|
|
180
|
+
answers: [{ type: "A", name, ttl: TTL_SECONDS, data: ip }],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// Shared query handler. Replies are sent back through the *same* socket
|
|
184
|
+
// the query arrived on so the reply's source address matches the address
|
|
185
|
+
// the client sent to — which is why each listener is bound to a specific
|
|
186
|
+
// IP rather than 0.0.0.0 (glibc drops answers whose source differs from
|
|
187
|
+
// the queried server).
|
|
188
|
+
async function handleQuery(sock, msg, rinfo) {
|
|
189
|
+
let query;
|
|
190
|
+
try {
|
|
191
|
+
query = dnsPacket.decode(msg);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const q = query.questions?.[0];
|
|
197
|
+
if (q && (q.type === "A" || q.type === "AAAA")) {
|
|
198
|
+
const name = q.name.toLowerCase();
|
|
199
|
+
const isSingleLabel = !name.includes(".");
|
|
200
|
+
// Fakes win over docker — they're explicitly registered by the
|
|
201
|
+
// daemon and a fake's hostname (e.g. api.stripe.com) might collide
|
|
202
|
+
// with a real upstream we don't want to call.
|
|
203
|
+
const fakeIp = isSingleLabel ? null : await lookupFake(name);
|
|
204
|
+
const ip = fakeIp
|
|
205
|
+
?? (isSingleLabel
|
|
206
|
+
? await dockerLookupByName(name)
|
|
207
|
+
: await dockerLookupByAlias(name));
|
|
208
|
+
if (ip) {
|
|
209
|
+
// For AAAA we still answer empty — Docker bridges are IPv4 only, but
|
|
210
|
+
// we own this name so libc should fall back to A instead of chasing
|
|
211
|
+
// a stray upstream AAAA for the real public hostname.
|
|
212
|
+
const resp = q.type === "A" ? aAnswer(query, q.name, ip) : emptyAnswer(query);
|
|
213
|
+
sock.send(resp, rinfo.port, rinfo.address);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (isSingleLabel) {
|
|
217
|
+
// Bare hostname we don't own — reply NOERROR with no answers so the
|
|
218
|
+
// resolver moves on quickly. Forwarding bare hostnames upstream just
|
|
219
|
+
// causes timeouts.
|
|
220
|
+
sock.send(emptyAnswer(query), rinfo.port, rinfo.address);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Multi-label exact miss: try wildcard suffixes (e.g. *.example.com
|
|
224
|
+
// → the k3s cluster) before forwarding upstream. Exact lookups above
|
|
225
|
+
// always win, so a specific alias beats a covering wildcard.
|
|
226
|
+
const wildIp = await lookupWildcard(name);
|
|
227
|
+
if (wildIp) {
|
|
228
|
+
const resp = q.type === "A" ? aAnswer(query, q.name, wildIp) : emptyAnswer(query);
|
|
229
|
+
sock.send(resp, rinfo.port, rinfo.address);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Still nothing — fall through and forward to upstream below.
|
|
233
|
+
}
|
|
234
|
+
// Anything we don't own — forward.
|
|
235
|
+
const upstream = await forwardUpstream(msg);
|
|
236
|
+
if (upstream) {
|
|
237
|
+
sock.send(upstream, rinfo.port, rinfo.address);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Upstream unreachable; return SERVFAIL (rcode 2 in low 4 bits of flags).
|
|
241
|
+
const resp = dnsPacket.encode({
|
|
242
|
+
type: "response",
|
|
243
|
+
id: query.id,
|
|
244
|
+
flags: dnsPacket.RECURSION_DESIRED | 0x2,
|
|
245
|
+
questions: query.questions ?? [],
|
|
246
|
+
});
|
|
247
|
+
sock.send(resp, rinfo.port, rinfo.address);
|
|
248
|
+
}
|
|
249
|
+
/** Bind one UDP listener on `addr`, wired to the shared handler. A bind
|
|
250
|
+
* error on the primary loopback address is fatal (DNS is fully down);
|
|
251
|
+
* on the secondary bridge address it's logged and retried by the caller. */
|
|
252
|
+
function bindListener(addr, fatalOnError) {
|
|
253
|
+
return new Promise((resolve) => {
|
|
254
|
+
const sock = createSocket("udp4");
|
|
255
|
+
sock.on("message", (msg, rinfo) => {
|
|
256
|
+
void handleQuery(sock, msg, rinfo);
|
|
257
|
+
});
|
|
258
|
+
sock.on("error", (err) => {
|
|
259
|
+
// eslint-disable-next-line no-console
|
|
260
|
+
console.error(`[spectest-resolver] socket error on ${addr}:`, err);
|
|
261
|
+
if (fatalOnError)
|
|
262
|
+
process.exit(1);
|
|
263
|
+
try {
|
|
264
|
+
sock.close();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// ignore
|
|
268
|
+
}
|
|
269
|
+
resolve(false);
|
|
270
|
+
});
|
|
271
|
+
sock.bind(LISTEN_PORT, addr, () => {
|
|
272
|
+
// eslint-disable-next-line no-console
|
|
273
|
+
console.log(`[spectest-resolver] listening on ${addr}:${LISTEN_PORT} (network=${NETWORK_NAME}, upstream=${UPSTREAM_DNS}:${UPSTREAM_PORT})`);
|
|
274
|
+
resolve(true);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/** Read the spectest-net bridge gateway IP from Docker — the address an
|
|
279
|
+
* in-cluster k3s pod (or any off-bridge client routed through the node)
|
|
280
|
+
* uses to reach the VM host. Null until the network exists. */
|
|
281
|
+
async function bridgeGatewayIp() {
|
|
282
|
+
const net = (await dockerGet(`/networks/${encodeURIComponent(NETWORK_NAME)}`));
|
|
283
|
+
const gw = net?.IPAM?.Config?.find((c) => c.Gateway)?.Gateway;
|
|
284
|
+
return gw && gw.length > 0 ? gw : null;
|
|
285
|
+
}
|
|
286
|
+
// Primary listener: the loopback nameserver. Fatal if it can't bind.
|
|
287
|
+
void bindListener(LISTEN_ADDR, true);
|
|
288
|
+
// Secondary listener: the spectest-net bridge gateway, for k3s pods.
|
|
289
|
+
// The network is created by the daemon at /load — after this process
|
|
290
|
+
// starts and after it's captured into the base snapshot — so poll until
|
|
291
|
+
// the gateway appears, bind once, then stop. The gateway is stable for a
|
|
292
|
+
// VM's life (the daemon only creates the network when it's missing), so a
|
|
293
|
+
// single successful bind survives snapshot/restore/fork. Opt out with
|
|
294
|
+
// SPECTEST_RESOLVER_NO_BRIDGE=1.
|
|
295
|
+
if (process.env.SPECTEST_RESOLVER_NO_BRIDGE !== "1") {
|
|
296
|
+
let bound = false;
|
|
297
|
+
const poll = setInterval(async () => {
|
|
298
|
+
if (bound)
|
|
299
|
+
return;
|
|
300
|
+
const gw = await bridgeGatewayIp().catch(() => null);
|
|
301
|
+
if (!gw)
|
|
302
|
+
return;
|
|
303
|
+
bound = true;
|
|
304
|
+
if (!(await bindListener(gw, false)))
|
|
305
|
+
bound = false; // retry on failure
|
|
306
|
+
}, 2_000);
|
|
307
|
+
// Don't keep the event loop alive solely for the poll timer.
|
|
308
|
+
poll.unref?.();
|
|
309
|
+
}
|
package/dist/s3.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Wrapped } from "./inspect.js";
|
|
2
|
+
/** Structural types for the parts of `Bun.S3Client` we use, declared locally so
|
|
3
|
+
* user projects don't need `@types/bun`. The real implementation comes from
|
|
4
|
+
* Bun at runtime via `globalThis.Bun.S3Client`. */
|
|
5
|
+
interface RawS3File {
|
|
6
|
+
name?: string;
|
|
7
|
+
bucket?: string;
|
|
8
|
+
size?: number;
|
|
9
|
+
type?: string;
|
|
10
|
+
text(): Promise<string>;
|
|
11
|
+
json<T = unknown>(): Promise<T>;
|
|
12
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
13
|
+
bytes(): Promise<Uint8Array>;
|
|
14
|
+
write(data: unknown, options?: unknown): Promise<number>;
|
|
15
|
+
exists(): Promise<boolean>;
|
|
16
|
+
delete(): Promise<void>;
|
|
17
|
+
unlink?(): Promise<void>;
|
|
18
|
+
stat(): Promise<unknown>;
|
|
19
|
+
presign(options?: unknown): string;
|
|
20
|
+
[m: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
interface RawS3Client {
|
|
23
|
+
file(path: string, options?: unknown): RawS3File;
|
|
24
|
+
write(path: string, data: unknown, options?: unknown): Promise<number>;
|
|
25
|
+
delete(path: string, options?: unknown): Promise<void>;
|
|
26
|
+
unlink?(path: string, options?: unknown): Promise<void>;
|
|
27
|
+
exists(path: string, options?: unknown): Promise<boolean>;
|
|
28
|
+
size?(path: string, options?: unknown): Promise<number>;
|
|
29
|
+
stat(path: string, options?: unknown): Promise<unknown>;
|
|
30
|
+
presign(path: string, options?: unknown): string;
|
|
31
|
+
list(input?: unknown, options?: unknown): Promise<unknown>;
|
|
32
|
+
[m: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
/** A lazy S3 object handle (Bun's `S3File`), wrapped so each read/write records
|
|
35
|
+
* an `s3` event and returns a {@link Wrapped} result. */
|
|
36
|
+
export interface S3File {
|
|
37
|
+
readonly name?: string;
|
|
38
|
+
readonly bucket?: string;
|
|
39
|
+
readonly size?: number;
|
|
40
|
+
text(): Promise<Wrapped<string>>;
|
|
41
|
+
json<T = unknown>(): Promise<Wrapped<T>>;
|
|
42
|
+
arrayBuffer(): Promise<Wrapped<ArrayBuffer>>;
|
|
43
|
+
bytes(): Promise<Wrapped<Uint8Array>>;
|
|
44
|
+
write(data: unknown, options?: unknown): Promise<Wrapped<number>>;
|
|
45
|
+
exists(): Promise<Wrapped<boolean>>;
|
|
46
|
+
delete(): Promise<Wrapped<void>>;
|
|
47
|
+
stat(): Promise<Wrapped<unknown>>;
|
|
48
|
+
presign(options?: unknown): string;
|
|
49
|
+
}
|
|
50
|
+
/** The instrumented S3 surface — same shape as `Bun.S3Client`, but every op
|
|
51
|
+
* records an `s3` event and resolves to a {@link Wrapped} result. */
|
|
52
|
+
export interface S3ClientLike {
|
|
53
|
+
file(path: string, options?: unknown): S3File;
|
|
54
|
+
write(path: string, data: unknown, options?: unknown): Promise<Wrapped<number>>;
|
|
55
|
+
delete(path: string, options?: unknown): Promise<Wrapped<void>>;
|
|
56
|
+
exists(path: string, options?: unknown): Promise<Wrapped<boolean>>;
|
|
57
|
+
stat(path: string, options?: unknown): Promise<Wrapped<unknown>>;
|
|
58
|
+
list(input?: unknown, options?: unknown): Promise<Wrapped<unknown>>;
|
|
59
|
+
presign(path: string, options?: unknown): string;
|
|
60
|
+
[m: string]: (...args: any[]) => any;
|
|
61
|
+
}
|
|
62
|
+
/** Options accepted by the {@link S3Client} constructor — Bun's `S3Options`,
|
|
63
|
+
* passed through to `Bun.S3Client` verbatim. */
|
|
64
|
+
export interface S3ClientOptions {
|
|
65
|
+
accessKeyId?: string;
|
|
66
|
+
secretAccessKey?: string;
|
|
67
|
+
sessionToken?: string;
|
|
68
|
+
bucket?: string;
|
|
69
|
+
region?: string;
|
|
70
|
+
endpoint?: string;
|
|
71
|
+
[opt: string]: unknown;
|
|
72
|
+
}
|
|
73
|
+
interface S3Constructor {
|
|
74
|
+
new (options?: S3ClientOptions): S3ClientLike;
|
|
75
|
+
(options?: S3ClientOptions): S3ClientLike;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Open an instrumented S3 client. Usable with or without `new`. Requires the
|
|
79
|
+
* Bun runtime — it runs inside the spectest daemon.
|
|
80
|
+
*/
|
|
81
|
+
export declare const S3Client: S3Constructor;
|
|
82
|
+
/**
|
|
83
|
+
* Proxy a `Bun.S3Client` so each op records an `s3` event. `file(path)` returns
|
|
84
|
+
* a wrapped {@link S3File}; unknown methods pass through.
|
|
85
|
+
*
|
|
86
|
+
* Exported so a client built another way can opt into the same instrumentation.
|
|
87
|
+
*/
|
|
88
|
+
export declare function instrumentS3(raw: RawS3Client, bucket?: string): S3ClientLike;
|
|
89
|
+
export {};
|
package/dist/s3.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
import { recordS3, reserveEvent } from "./recorder.js";
|
|
14
|
+
import { wrap } from "./inspect.js";
|
|
15
|
+
const MAX_PREVIEW = 512;
|
|
16
|
+
/**
|
|
17
|
+
* Open an instrumented S3 client. Usable with or without `new`. Requires the
|
|
18
|
+
* Bun runtime — it runs inside the spectest daemon.
|
|
19
|
+
*/
|
|
20
|
+
export const S3Client = function S3Client(options) {
|
|
21
|
+
const bun = globalThis.Bun;
|
|
22
|
+
if (!bun?.S3Client) {
|
|
23
|
+
throw new Error("S3Client requires the Bun runtime (Bun >= 1.1) — it runs inside the spectest daemon.");
|
|
24
|
+
}
|
|
25
|
+
return instrumentS3(new bun.S3Client(options), options?.bucket);
|
|
26
|
+
};
|
|
27
|
+
/** Record an awaitable S3 op, wrapping its settled result for provenance. */
|
|
28
|
+
function runS3(base, invoke, describe) {
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
const resv = reserveEvent();
|
|
31
|
+
return Promise.resolve(invoke()).then((value) => {
|
|
32
|
+
const seq = recordS3({ ...base, ...(describe?.(value) ?? {}), durationMs: Date.now() - started }, resv);
|
|
33
|
+
return wrap(value, seq);
|
|
34
|
+
}, (err) => {
|
|
35
|
+
const e = err;
|
|
36
|
+
recordS3({ ...base, durationMs: Date.now() - started, error: e?.message ?? String(err) }, resv);
|
|
37
|
+
throw err;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Record a synchronous op (`presign`) — no result wrapping (returns a URL). */
|
|
41
|
+
function runS3Sync(base, invoke) {
|
|
42
|
+
const started = Date.now();
|
|
43
|
+
try {
|
|
44
|
+
const value = invoke();
|
|
45
|
+
recordS3({ ...base, durationMs: Date.now() - started }, undefined);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const e = err;
|
|
50
|
+
recordS3({ ...base, durationMs: Date.now() - started, error: e?.message ?? String(err) }, undefined);
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Proxy a `Bun.S3Client` so each op records an `s3` event. `file(path)` returns
|
|
56
|
+
* a wrapped {@link S3File}; unknown methods pass through.
|
|
57
|
+
*
|
|
58
|
+
* Exported so a client built another way can opt into the same instrumentation.
|
|
59
|
+
*/
|
|
60
|
+
export function instrumentS3(raw, bucket) {
|
|
61
|
+
const handler = {
|
|
62
|
+
get(target, prop, receiver) {
|
|
63
|
+
if (typeof prop !== "string")
|
|
64
|
+
return Reflect.get(target, prop, receiver);
|
|
65
|
+
switch (prop) {
|
|
66
|
+
case "file":
|
|
67
|
+
return (path, options) => instrumentS3File(target.file(path, options), path, bucket);
|
|
68
|
+
case "write":
|
|
69
|
+
return (path, data, options) => runS3({ op: "write", bucket, key: path }, () => target.write(path, data, options), () => describeData(data));
|
|
70
|
+
case "delete":
|
|
71
|
+
case "unlink":
|
|
72
|
+
return (path, options) => runS3({ op: "delete", bucket, key: path }, () => target.delete(path, options));
|
|
73
|
+
case "exists":
|
|
74
|
+
return (path, options) => runS3({ op: "exists", bucket, key: path }, () => target.exists(path, options));
|
|
75
|
+
case "size":
|
|
76
|
+
return (path, options) => runS3({ op: "stat", bucket, key: path }, () => target.size(path, options), (n) => ({
|
|
77
|
+
size: typeof n === "number" ? n : undefined,
|
|
78
|
+
}));
|
|
79
|
+
case "stat":
|
|
80
|
+
return (path, options) => runS3({ op: "stat", bucket, key: path }, () => target.stat(path, options), describeStat);
|
|
81
|
+
case "list":
|
|
82
|
+
return (input, options) => runS3({ op: "list", bucket }, () => target.list(input, options), (r) => ({
|
|
83
|
+
count: listCount(r),
|
|
84
|
+
}));
|
|
85
|
+
case "presign":
|
|
86
|
+
return (path, options) => runS3Sync({ op: "presign", bucket, key: path }, () => target.presign(path, options));
|
|
87
|
+
default: {
|
|
88
|
+
const v = Reflect.get(target, prop, receiver);
|
|
89
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
return new Proxy(raw, handler);
|
|
95
|
+
}
|
|
96
|
+
function instrumentS3File(raw, key, bucket) {
|
|
97
|
+
const b = raw.bucket ?? bucket;
|
|
98
|
+
const handler = {
|
|
99
|
+
get(target, prop, receiver) {
|
|
100
|
+
if (typeof prop !== "string")
|
|
101
|
+
return Reflect.get(target, prop, receiver);
|
|
102
|
+
switch (prop) {
|
|
103
|
+
case "text":
|
|
104
|
+
return () => runS3({ op: "read", bucket: b, key }, () => target.text(), describeData);
|
|
105
|
+
case "json":
|
|
106
|
+
return () => runS3({ op: "read", bucket: b, key }, () => target.json(), describeData);
|
|
107
|
+
case "arrayBuffer":
|
|
108
|
+
return () => runS3({ op: "read", bucket: b, key }, () => target.arrayBuffer(), describeData);
|
|
109
|
+
case "bytes":
|
|
110
|
+
return () => runS3({ op: "read", bucket: b, key }, () => target.bytes(), describeData);
|
|
111
|
+
case "write":
|
|
112
|
+
return (data, options) => runS3({ op: "write", bucket: b, key }, () => target.write(data, options), () => describeData(data));
|
|
113
|
+
case "exists":
|
|
114
|
+
return () => runS3({ op: "exists", bucket: b, key }, () => target.exists());
|
|
115
|
+
case "delete":
|
|
116
|
+
case "unlink":
|
|
117
|
+
return () => runS3({ op: "delete", bucket: b, key }, () => target.delete());
|
|
118
|
+
case "stat":
|
|
119
|
+
return () => runS3({ op: "stat", bucket: b, key }, () => target.stat(), describeStat);
|
|
120
|
+
case "presign":
|
|
121
|
+
return (options) => runS3Sync({ op: "presign", bucket: b, key }, () => target.presign(options));
|
|
122
|
+
default: {
|
|
123
|
+
const v = Reflect.get(target, prop, receiver);
|
|
124
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
return new Proxy(raw, handler);
|
|
130
|
+
}
|
|
131
|
+
/** Byte size + small text preview of a written/read body. */
|
|
132
|
+
function describeData(value) {
|
|
133
|
+
if (typeof value === "string") {
|
|
134
|
+
const truncated = value.length > MAX_PREVIEW;
|
|
135
|
+
return {
|
|
136
|
+
size: byteLength(value),
|
|
137
|
+
preview: truncated ? value.slice(0, MAX_PREVIEW) : value,
|
|
138
|
+
previewTruncated: truncated || undefined,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (value && typeof value === "object" && !isBinary(value)) {
|
|
142
|
+
// A parsed JSON body (from `.json()`): preview a capped serialization.
|
|
143
|
+
try {
|
|
144
|
+
const json = JSON.stringify(value);
|
|
145
|
+
const truncated = json.length > MAX_PREVIEW;
|
|
146
|
+
return {
|
|
147
|
+
contentType: "application/json",
|
|
148
|
+
preview: truncated ? `${json.slice(0, MAX_PREVIEW)}…` : json,
|
|
149
|
+
previewTruncated: truncated || undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* fall through to size-only */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const size = byteLength(value);
|
|
157
|
+
return size !== undefined ? { size } : {};
|
|
158
|
+
}
|
|
159
|
+
function describeStat(stat) {
|
|
160
|
+
if (stat && typeof stat === "object") {
|
|
161
|
+
const s = stat;
|
|
162
|
+
return {
|
|
163
|
+
size: typeof s.size === "number" ? s.size : undefined,
|
|
164
|
+
contentType: typeof s.type === "string"
|
|
165
|
+
? s.type
|
|
166
|
+
: typeof s.contentType === "string"
|
|
167
|
+
? s.contentType
|
|
168
|
+
: undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
function listCount(result) {
|
|
174
|
+
if (result && typeof result === "object") {
|
|
175
|
+
const r = result;
|
|
176
|
+
if (Array.isArray(r.contents))
|
|
177
|
+
return r.contents.length;
|
|
178
|
+
if (typeof r.keyCount === "number")
|
|
179
|
+
return r.keyCount;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
function isBinary(value) {
|
|
184
|
+
return (value instanceof ArrayBuffer ||
|
|
185
|
+
ArrayBuffer.isView(value) ||
|
|
186
|
+
(typeof Blob !== "undefined" && value instanceof Blob));
|
|
187
|
+
}
|
|
188
|
+
function byteLength(value) {
|
|
189
|
+
if (typeof value === "string")
|
|
190
|
+
return Buffer.byteLength(value);
|
|
191
|
+
if (value instanceof ArrayBuffer)
|
|
192
|
+
return value.byteLength;
|
|
193
|
+
if (ArrayBuffer.isView(value))
|
|
194
|
+
return value.byteLength;
|
|
195
|
+
if (typeof Blob !== "undefined" && value instanceof Blob)
|
|
196
|
+
return value.size;
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
package/dist/sql.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Wrapped } from "./inspect.js";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal structural type for the parts of `Bun.SQL` we use. Declared locally
|
|
4
|
+
* so user projects don't need `@types/bun` for type-checking to follow the SDK
|
|
5
|
+
* through to the daemon. The real type comes from Bun at runtime via
|
|
6
|
+
* `globalThis.Bun.SQL`.
|
|
7
|
+
*/
|
|
8
|
+
interface RawSqlClient {
|
|
9
|
+
<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
|
|
10
|
+
unsafe<T = unknown>(text: string, params?: unknown[]): Promise<T[]>;
|
|
11
|
+
close?(opts?: {
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
end(opts?: {
|
|
15
|
+
timeout?: number;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
interface SqlClientBase {
|
|
19
|
+
close?(opts?: {
|
|
20
|
+
timeout?: number;
|
|
21
|
+
}): Promise<void>;
|
|
22
|
+
/** Close the connection / drain the pool (Bun's `SQL.end`). */
|
|
23
|
+
end(opts?: {
|
|
24
|
+
timeout?: number;
|
|
25
|
+
}): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The instrumented SQL surface. Same shape as `Bun.SQL` — callable as a tagged
|
|
29
|
+
* template (`` sql`SELECT 1` ``) with an `unsafe(text, params?)` escape hatch —
|
|
30
|
+
* but each query resolves to a **wrapped** row array ({@link Wrapped}): every
|
|
31
|
+
* settle is recorded as a `db` event and the wrapper carries provenance back to
|
|
32
|
+
* it. That's why `expect(rows)` / `expect(rows[0]!.id)` link under the query in
|
|
33
|
+
* the timeline with no cast — and why you `.unwrap()` before feeding a row value
|
|
34
|
+
* to a real client or a `===`.
|
|
35
|
+
*
|
|
36
|
+
* Generic in the row type, so callers name the shape once at the call site
|
|
37
|
+
* instead of casting: `` await client<TodoRow>`SELECT * FROM todos` `` is
|
|
38
|
+
* `Promise<Wrapped<TodoRow[]>>`.
|
|
39
|
+
*/
|
|
40
|
+
export interface SqlClient extends SqlClientBase {
|
|
41
|
+
<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<Wrapped<T[]>>;
|
|
42
|
+
unsafe<T = unknown>(text: string, params?: unknown[]): Promise<Wrapped<T[]>>;
|
|
43
|
+
}
|
|
44
|
+
/** Options accepted by the {@link SQL} constructor beyond Bun's own. */
|
|
45
|
+
export interface SqlOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Label shown on each recorded `db` event (the timeline step's service tag).
|
|
48
|
+
* Defaults to the connection URL's host — e.g. `db` for
|
|
49
|
+
* `postgres://…@db:5432/app` — which is usually the service name.
|
|
50
|
+
*/
|
|
51
|
+
label?: string;
|
|
52
|
+
}
|
|
53
|
+
interface SqlConstructor {
|
|
54
|
+
new (url: string, opts?: SqlOptions): SqlClient;
|
|
55
|
+
(url: string, opts?: SqlOptions): SqlClient;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Open an instrumented SQL client against `url`. Usable with or without `new`
|
|
59
|
+
* (`new SQL(url)` mirrors `new Bun.SQL(url)`; a constructor that returns an
|
|
60
|
+
* object yields that object). Requires the Bun runtime — it runs inside the
|
|
61
|
+
* spectest daemon.
|
|
62
|
+
*/
|
|
63
|
+
export declare const SQL: SqlConstructor;
|
|
64
|
+
/**
|
|
65
|
+
* Proxy a `Bun.SQL` instance so each tagged-template call and each
|
|
66
|
+
* `unsafe(...)` call emits a `db` event into the active recorder when its
|
|
67
|
+
* promise settles, and resolves to a {@link Wrapped} result. Other `Bun.SQL`
|
|
68
|
+
* methods (`.transaction`, `.array`, `.file`, …) pass through unwrapped.
|
|
69
|
+
*
|
|
70
|
+
* Exported so a client built some other way (e.g. a pool you already hold) can
|
|
71
|
+
* opt into the same instrumentation without going through {@link SQL}.
|
|
72
|
+
*/
|
|
73
|
+
export declare function instrumentSql(raw: RawSqlClient, label: string): SqlClient;
|
|
74
|
+
export {};
|