@beignet/core
Version:
Core framework primitives for Beignet
468 lines (419 loc) • 11.3 kB
text/typescript
/**
* Object visibility understood by storage ports.
*/
export type StorageVisibility = "private" | "public";
/**
* String metadata stored alongside an object.
*/
export type StorageMetadata = Record<string, string>;
/**
* Body types accepted by `StoragePort.put(...)`.
*/
export type StorageBody =
| string
| ArrayBuffer
| Uint8Array
| Blob
| ReadableStream<Uint8Array>;
/**
* Options for writing an object to storage.
*/
export interface StoragePutOptions {
/**
* MIME content type stored with the object.
*/
contentType?: string;
/**
* Cache-Control value stored with the object.
*/
cacheControl?: string;
/**
* Provider metadata stored with the object.
*/
metadata?: StorageMetadata;
/**
* Whether the object may receive a public URL.
*/
visibility?: StorageVisibility;
}
/**
* Metadata for an object in storage.
*/
export interface StorageObject {
/**
* Object key. Keys are relative object-store paths, not filesystem paths or
* public URLs.
*/
key: string;
/**
* Object size in bytes.
*/
size: number;
contentType?: string;
cacheControl?: string;
/**
* Provider metadata stored with the object.
*/
metadata: StorageMetadata;
/**
* Object visibility.
*/
visibility: StorageVisibility;
/**
* Last modification timestamp.
*/
lastModified: Date;
}
/**
* Object metadata plus a one-shot readable body.
*
* Like Fetch response bodies, storage bodies can be consumed once. Call `get`
* again if you need another reader.
*/
export interface StorageObjectBody extends StorageObject {
/**
* Whether this object body has already been consumed. Like Fetch response
* bodies, storage bodies are one-shot so providers can stream objects without
* buffering them.
*/
readonly bodyUsed: boolean;
/**
* Consume the object as a readable byte stream.
*/
stream(): ReadableStream<Uint8Array>;
/**
* Consume the object as bytes.
*/
bytes(): Promise<Uint8Array>;
/**
* Consume the object as an ArrayBuffer.
*/
arrayBuffer(): Promise<ArrayBuffer>;
/**
* Consume the object as UTF-8 text.
*/
text(): Promise<string>;
}
/**
* App-facing object storage port.
*
* Implement this with S3, R2, local disk, or a test adapter. Application code
* should depend on this interface instead of provider-specific SDKs.
*/
export interface StoragePort {
/**
* Store an object and return its metadata.
*/
put(
key: string,
body: StorageBody,
options?: StoragePutOptions,
): Promise<StorageObject>;
/**
* Return object metadata and body, or `null` when missing.
*/
get(key: string): Promise<StorageObjectBody | null>;
/**
* Return object metadata without its body, or `null` when missing.
*/
stat(key: string): Promise<StorageObject | null>;
/**
* Delete an object.
*
* @returns `true` when the object existed.
*/
delete(key: string): Promise<boolean>;
/**
* Return whether an object exists.
*/
exists(key: string): Promise<boolean>;
/**
* Return a public URL when the object is public and the adapter can build one.
*/
publicUrl(key: string): Promise<string | null>;
}
/**
* Options for prefixing one validated storage key.
*/
export interface PrefixStorageKeyOptions {
/**
* Optional app or environment prefix. Leading and trailing slashes are
* removed before it is applied.
*/
keyPrefix?: string;
/**
* Relative object key to prefix.
*/
key: string;
}
/**
* Options for formatting a public storage URL.
*/
export interface CreateStoragePublicUrlOptions {
/**
* Absolute or app-relative public base URL.
*/
publicBaseUrl: string;
/**
* Relative object key appended to the public base URL.
*/
key: string;
}
function hasControlCharacter(value: string): boolean {
for (const char of value) {
const code = char.charCodeAt(0);
if (code <= 31 || code === 127) return true;
}
return false;
}
/**
* Assert that a storage key follows Beignet's provider-neutral key rules.
*
* Valid keys are non-empty relative object paths. They do not contain control
* characters, backslashes, empty path segments, or `.` / `..` segments.
* Providers may enforce additional adapter-specific restrictions after this
* shared assertion.
*/
export function assertValidStorageKey(key: string): void {
if (key.length === 0) {
throw new Error("Storage key must not be empty.");
}
if (hasControlCharacter(key)) {
throw new Error("Storage key must not include control characters.");
}
if (key.startsWith("/")) {
throw new Error("Storage key must not start with '/'.");
}
if (key.endsWith("/")) {
throw new Error("Storage key must not end with '/'.");
}
if (key.includes("\\")) {
throw new Error("Storage key must use '/' separators, not '\\'.");
}
const segments = key.split("/");
if (segments.some((segment) => segment === "")) {
throw new Error("Storage key must not include empty path segments.");
}
if (segments.some((segment) => segment === "." || segment === "..")) {
throw new Error("Storage key must not include '.' or '..' segments.");
}
}
/**
* Normalize and validate an optional storage key prefix.
*
* Empty and slash-only prefixes normalize to an empty string.
*/
export function normalizeStorageKeyPrefix(prefix: string | undefined): string {
if (!prefix) return "";
const normalized = prefix.replace(/^\/+|\/+$/g, "");
if (!normalized) return "";
assertValidStorageKey(normalized);
return normalized;
}
/**
* Prefix a storage key with an optional app or environment namespace.
*/
export function prefixStorageKey({
keyPrefix,
key,
}: PrefixStorageKeyOptions): string {
const normalizedPrefix = normalizeStorageKeyPrefix(keyPrefix);
assertValidStorageKey(key);
return normalizedPrefix ? `${normalizedPrefix}/${key}` : key;
}
/**
* Format an encoded public URL for a validated storage key.
*/
export function createStoragePublicUrl({
publicBaseUrl,
key,
}: CreateStoragePublicUrlOptions): string {
assertValidStorageKey(key);
const base = publicBaseUrl.replace(/\/+$/, "");
const encodedKey = key
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
return `${base}/${encodedKey}`;
}
/**
* Options for `createMemoryStorage(...)`.
*/
export interface MemoryStorageOptions {
/**
* Base URL used by `publicUrl(...)` for objects written with
* `visibility: "public"`.
*/
publicBaseUrl?: string;
}
type MemoryStorageEntry = StorageObject & {
bytes: Uint8Array;
};
function copyBytes(bytes: Uint8Array): Uint8Array {
return new Uint8Array(bytes);
}
function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
const copy = copyBytes(bytes);
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(copy);
controller.close();
},
});
}
async function streamToBytes(
stream: ReadableStream<Uint8Array>,
): Promise<Uint8Array> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const result = await reader.read();
if (result.done) break;
chunks.push(result.value);
size += result.value.byteLength;
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
async function storageBodyToBytes(body: StorageBody): Promise<Uint8Array> {
if (typeof body === "string") {
return new TextEncoder().encode(body);
}
if (body instanceof Uint8Array) {
return copyBytes(body);
}
if (body instanceof ArrayBuffer) {
return new Uint8Array(body.slice(0));
}
if (body instanceof Blob) {
return new Uint8Array(await body.arrayBuffer());
}
return streamToBytes(body);
}
function cloneObject(entry: MemoryStorageEntry): StorageObject {
return {
key: entry.key,
size: entry.size,
...(entry.contentType !== undefined
? { contentType: entry.contentType }
: {}),
...(entry.cacheControl !== undefined
? { cacheControl: entry.cacheControl }
: {}),
metadata: { ...entry.metadata },
visibility: entry.visibility,
lastModified: new Date(entry.lastModified),
};
}
function createObjectBody(entry: MemoryStorageEntry): StorageObjectBody {
const object = cloneObject(entry);
let bodyUsed = false;
function consumeBytes(): Uint8Array {
if (bodyUsed) {
throw new Error("Storage object body has already been consumed.");
}
bodyUsed = true;
return copyBytes(entry.bytes);
}
return {
...object,
get bodyUsed() {
return bodyUsed;
},
stream() {
return bytesToStream(consumeBytes());
},
async bytes() {
return consumeBytes();
},
async arrayBuffer() {
return bytesToArrayBuffer(consumeBytes());
},
async text() {
return new TextDecoder().decode(consumeBytes());
},
};
}
/**
* Create an in-memory object storage adapter for tests, examples, and
* single-process development.
*
* This adapter validates object keys using Beignet's storage key rules. It is
* not durable and does not share objects across processes.
*
* @param options - Optional public URL base for public objects.
* @returns A storage port backed by a local `Map`.
*/
export function createMemoryStorage(
options: MemoryStorageOptions = {},
): StoragePort {
const objects = new Map<string, MemoryStorageEntry>();
return {
async put(key, body, putOptions) {
assertValidStorageKey(key);
const bytes = await storageBodyToBytes(body);
const entry: MemoryStorageEntry = {
key,
size: bytes.byteLength,
...(putOptions?.contentType !== undefined
? { contentType: putOptions.contentType }
: {}),
...(putOptions?.cacheControl !== undefined
? { cacheControl: putOptions.cacheControl }
: {}),
metadata: { ...(putOptions?.metadata ?? {}) },
visibility: putOptions?.visibility ?? "private",
lastModified: new Date(),
bytes,
};
objects.set(key, entry);
return cloneObject(entry);
},
async get(key) {
assertValidStorageKey(key);
const entry = objects.get(key);
if (!entry) return null;
return createObjectBody(entry);
},
async stat(key) {
assertValidStorageKey(key);
const entry = objects.get(key);
if (!entry) return null;
return cloneObject(entry);
},
async delete(key) {
assertValidStorageKey(key);
return objects.delete(key);
},
async exists(key) {
assertValidStorageKey(key);
return objects.has(key);
},
async publicUrl(key) {
assertValidStorageKey(key);
const entry = objects.get(key);
if (entry?.visibility !== "public" || !options.publicBaseUrl) {
return null;
}
return createStoragePublicUrl({
publicBaseUrl: options.publicBaseUrl,
key,
});
},
};
}