AGENTS.md asks for top-level `import type` instead of inline `type` specifiers; this applies it to the import statements the media work added or changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
240 lines
7.1 KiB
TypeScript
240 lines
7.1 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import type { FileHandle } from "node:fs/promises";
|
|
import { lstat, mkdir, open, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type {
|
|
AdapterContext,
|
|
ArtifactByteRange,
|
|
ArtifactPut,
|
|
ArtifactStore,
|
|
ArtifactStreamPut,
|
|
NotificationMessage,
|
|
NotificationProvider,
|
|
StoredArtifactStream,
|
|
} from "@rakazo/adapter-kit";
|
|
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
|
|
|
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
const READ_CHUNK_BYTES = 256 * 1024;
|
|
/** Space ids and artifact keys are generated ids; anything else could address another path. */
|
|
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
|
|
export class LocalArtifactStore implements ArtifactStore {
|
|
constructor(private readonly root: string) {}
|
|
|
|
describe() {
|
|
return {
|
|
id: "local-artifacts",
|
|
contractVersion: "1",
|
|
adapterVersion: "0.1.0",
|
|
capabilities: { stream: true },
|
|
};
|
|
}
|
|
|
|
private spaceDir(context: AdapterContext) {
|
|
if (!SAFE_SEGMENT.test(context.spaceId)) throw new Error("Invalid artifact space");
|
|
return path.join(this.root, "artifacts", context.spaceId);
|
|
}
|
|
|
|
private file(key: string, context: AdapterContext) {
|
|
if (!SAFE_SEGMENT.test(key)) throw new Error("Invalid artifact key");
|
|
return path.join(this.spaceDir(context), key);
|
|
}
|
|
|
|
/** Creates a new owner-only file under a fresh key; never follows or replaces an existing path. */
|
|
private async create(context: AdapterContext) {
|
|
const dir = this.spaceDir(context);
|
|
await mkdir(dir, { recursive: true });
|
|
const key = randomUUID();
|
|
const file = path.join(dir, key);
|
|
const handle = await open(
|
|
file,
|
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW,
|
|
0o600,
|
|
);
|
|
return { key, file, handle };
|
|
}
|
|
|
|
private openForRead(key: string, context: AdapterContext) {
|
|
return open(this.file(key, context), constants.O_RDONLY | O_NOFOLLOW);
|
|
}
|
|
|
|
async put(artifact: ArtifactPut, context: AdapterContext) {
|
|
const { key, handle } = await this.create(context);
|
|
try {
|
|
await handle.writeFile(artifact.bytes);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
return { id: key, hash: String(artifact.bytes.byteLength) };
|
|
}
|
|
|
|
async putStream(
|
|
artifact: ArtifactStreamPut,
|
|
context: AdapterContext,
|
|
): Promise<StoredArtifactStream> {
|
|
if (!Number.isSafeInteger(artifact.maxBytes) || artifact.maxBytes < 0) {
|
|
throw new Error("maxBytes must be a non-negative integer");
|
|
}
|
|
const { key, file, handle } = await this.create(context);
|
|
const hash = createHash("sha256");
|
|
let size = 0;
|
|
try {
|
|
try {
|
|
for await (const chunk of byteChunks(artifact.stream)) {
|
|
context.signal.throwIfAborted();
|
|
size += chunk.byteLength;
|
|
if (size > artifact.maxBytes) throw new ArtifactTooLargeError(artifact.maxBytes);
|
|
hash.update(chunk);
|
|
await writeFully(handle, chunk);
|
|
}
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
} catch (error) {
|
|
await rm(file, { force: true }).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
return { key, size, sha256: hash.digest("hex") };
|
|
}
|
|
|
|
async get(id: string, context: AdapterContext) {
|
|
const handle = await this.openForRead(id, context);
|
|
try {
|
|
return new Uint8Array(await handle.readFile());
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async stat(key: string, context: AdapterContext) {
|
|
const handle = await this.openForRead(key, context);
|
|
try {
|
|
const info = await handle.stat();
|
|
if (!info.isFile()) throw new Error("Artifact is not a file");
|
|
return { size: info.size };
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async createReadStream(key: string, context: AdapterContext, range?: ArtifactByteRange) {
|
|
const handle = await this.openForRead(key, context);
|
|
try {
|
|
const info = await handle.stat();
|
|
if (!info.isFile()) throw new Error("Artifact is not a file");
|
|
const start = range?.start ?? 0;
|
|
const end = range?.end ?? info.size - 1;
|
|
if (
|
|
range &&
|
|
(!Number.isSafeInteger(start) ||
|
|
!Number.isSafeInteger(end) ||
|
|
start < 0 ||
|
|
end < start ||
|
|
end >= info.size)
|
|
) {
|
|
throw new RangeError("Range does not fit the artifact");
|
|
}
|
|
return fileByteStream(handle, start, end);
|
|
} catch (error) {
|
|
await handle.close();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async localPath(key: string, context: AdapterContext) {
|
|
const file = this.file(key, context);
|
|
const info = await lstat(file);
|
|
if (!info.isFile()) throw new Error("Artifact is not a file");
|
|
return file;
|
|
}
|
|
|
|
async remove(id: string, context: AdapterContext) {
|
|
await rm(this.file(id, context), { force: true });
|
|
}
|
|
}
|
|
|
|
async function* byteChunks(
|
|
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
): AsyncIterable<Uint8Array> {
|
|
if (!("getReader" in source)) {
|
|
yield* source;
|
|
return;
|
|
}
|
|
const reader = source.getReader();
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) return;
|
|
yield value;
|
|
}
|
|
} finally {
|
|
// Stop the producer (e.g. an upload body) when the store gives up early, without waiting on
|
|
// a producer that may never confirm.
|
|
void reader.cancel().catch(() => undefined);
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
async function writeFully(handle: FileHandle, chunk: Uint8Array) {
|
|
let offset = 0;
|
|
while (offset < chunk.byteLength) {
|
|
const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
|
|
offset += bytesWritten;
|
|
}
|
|
}
|
|
|
|
/** Streams [start, end] of an open file and closes it when done, failed or cancelled. */
|
|
function fileByteStream(handle: FileHandle, start: number, end: number) {
|
|
let position = start;
|
|
let closed = false;
|
|
const close = async () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
await handle.close().catch(() => undefined);
|
|
};
|
|
return new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
try {
|
|
const length = Math.min(end - position + 1, READ_CHUNK_BYTES);
|
|
if (length <= 0) {
|
|
await close();
|
|
controller.close();
|
|
return;
|
|
}
|
|
const buffer = new Uint8Array(length);
|
|
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
if (bytesRead === 0) throw new Error("Artifact ended before the requested range");
|
|
position += bytesRead;
|
|
controller.enqueue(bytesRead === length ? buffer : buffer.subarray(0, bytesRead));
|
|
if (position > end) {
|
|
await close();
|
|
controller.close();
|
|
}
|
|
} catch (error) {
|
|
await close();
|
|
controller.error(error);
|
|
}
|
|
},
|
|
cancel: close,
|
|
});
|
|
}
|
|
|
|
export class CapturingNotificationProvider implements NotificationProvider {
|
|
readonly sent: NotificationMessage[] = [];
|
|
|
|
describe() {
|
|
return {
|
|
id: "capturing",
|
|
contractVersion: "1",
|
|
adapterVersion: "0.1.0",
|
|
capabilities: { push: false, email: false },
|
|
};
|
|
}
|
|
|
|
async send(message: NotificationMessage, _context: AdapterContext): Promise<void> {
|
|
this.sent.push(message);
|
|
}
|
|
}
|