UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

468 lines (419 loc) 11.3 kB
/** * 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, }); }, }; }