ML-173: replace aws-sdk with aws4 + built-in fetch

Replace @aws-sdk/client-s3 (~150 transitive deps, ~14MB) with aws4
(0 transitive deps, ~40KB) and Node.js built-in fetch+crypto for
S3 ListObjectsV2 against Hetzner Object Storage.

- New s3-client.ts: SigV4 signing via aws4, regex-based XML parsing
  with namespace tolerance, pagination support, 5 error categories
- 29 unit tests (node:test, zero-dependency) covering signing,
  parsing, entities, missing fields, pagination, abort, errors
- Settlement guard for double done(null) on abort
- XML entity decoding and regex metacharacter escaping
- Dependency surface: 150 packages → 1 (aws4, single-file, auditable)
This commit is contained in:
Claudio Ortolina
2026-05-09 10:44:10 +01:00
parent d21cbb306c
commit 6f865a7b4d
8 changed files with 1133 additions and 1605 deletions
+43 -61
View File
@@ -12,7 +12,6 @@
* /backups
*/
import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { BorderedLoader, DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
@@ -21,6 +20,15 @@ import {
SelectList,
Text,
} from "@mariozechner/pi-tui";
import { listAllObjects, type S3ClientConfig } from "./s3-client";
// Re-export for consumers
export {
AuthError,
NetworkError,
AbortError,
type S3Object,
} from "./s3-client";
// ── S3 configuration (mirrors scripts/prod/litestream-backup) ────────────────
const S3_ENDPOINT = "https://nbg1.your-objectstorage.com";
@@ -54,55 +62,6 @@ function displayKey(key: string): string {
return key;
}
export interface S3Object {
key: string;
size: number;
lastModified: Date;
}
/**
* Fetch all objects (handles pagination via ContinuationToken).
*/
async function listAllObjects(
client: S3Client,
signal?: AbortSignal,
): Promise<S3Object[]> {
const results: S3Object[] = [];
let continuationToken: string | undefined;
do {
const command = new ListObjectsV2Command({
Bucket: S3_BUCKET,
Prefix: S3_PREFIX,
...(continuationToken ? { ContinuationToken: continuationToken } : {}),
});
const response = await client.send(command, { abortSignal: signal });
if (response.Contents) {
for (const obj of response.Contents) {
results.push({
key: obj.Key ?? "",
size: obj.Size ?? 0,
lastModified: obj.LastModified ?? new Date(0),
});
}
}
continuationToken = response.IsTruncated
? response.NextContinuationToken
: undefined;
} while (continuationToken);
// Sort by size descending, then alphabetically descending
results.sort((a, b) => {
const sizeDiff = b.size - a.size;
if (sizeDiff !== 0) return sizeDiff;
return b.key.localeCompare(a.key);
});
return results;
}
// ── Extension ───────────────────────────────────────────────────────────────
export default function s3BrowserExtension(pi: ExtensionAPI) {
@@ -131,23 +90,46 @@ export default function s3BrowserExtension(pi: ExtensionAPI) {
theme,
"Fetching backup list from S3...",
);
loader.onAbort = () => done(null);
const doFetch = async (): Promise<S3Object[]> => {
const client = new S3Client({
const config: S3ClientConfig = {
endpoint: new URL(S3_ENDPOINT).host,
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: { accessKeyId, secretAccessKey },
forcePathStyle: true,
});
return listAllObjects(client, loader.signal);
bucket: S3_BUCKET,
accessKeyId,
secretAccessKey,
};
return listAllObjects(config, { prefix: S3_PREFIX }, loader.signal);
};
let settled = false;
const finish = (result: S3Object[] | null) => {
if (!settled) {
settled = true;
done(result);
}
};
loader.onAbort = () => finish(null);
doFetch()
.then(done)
.catch((err) => {
console.error("[s3-browser] Fetch failed:", err);
done(null);
.then(finish)
.catch((err: unknown) => {
if (err instanceof AuthError) {
ctx.ui.notify(
"Authentication failed — check LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEY",
"error",
);
} else if (err instanceof NetworkError) {
ctx.ui.notify(
"Could not reach S3 endpoint — check network",
"error",
);
} else if (err instanceof AbortError) {
// User cancelled — clean cancellation, no notification needed
} else {
console.error("[s3-browser] Fetch failed:", err);
}
finish(null);
});
return loader;