Spaces:
Running
Running
File size: 11,286 Bytes
0ccd578 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | import type { UserContext } from './context';
import { RuntimeTelemetry, startRuntimeOperation } from './telemetry';
export type StorageErrorCode = 'InvalidInput' | 'NotConfigured' | 'NotFound' | 'AccessDenied' | 'Conflict' | 'Timeout' | 'Unavailable' | 'Unsupported' | 'TooLarge' | 'Io';
export class StorageError extends Error {
constructor(public readonly code: StorageErrorCode) { super(`Object storage: ${code}`); this.name = 'StorageError'; }
}
const inspect = Symbol.for('nodejs.util.inspect.custom');
export const MAX_OBJECT_PUT_SIZE = 5 * 1024 ** 3;
const invalid = (): never => { throw new StorageError('InvalidInput'); };
function header(value: string): string { if (typeof value !== 'string' || !value || /[^\x20-\x7e]/.test(value)) invalid(); return value; }
export class ObjectKey {
#value: string;
constructor(value: string) {
if (typeof value !== 'string' || !value || new TextEncoder().encode(value).length > 1024 || /[\x00-\x1f\x7f-\x9f]/.test(value)) invalid();
// Reject isolated UTF-16 surrogates; TextEncoder would otherwise replace them.
for (let i=0;i<value.length;i++) { const c=value.charCodeAt(i); if(c>=0xd800&&c<=0xdbff){const n=value.charCodeAt(++i);if(!(n>=0xdc00&&n<=0xdfff))invalid();} else if(c>=0xdc00&&c<=0xdfff)invalid(); }
this.#value = value;
}
get value(): string { return this.#value; }
toString(): string { return 'ObjectKey[masked]'; }
[inspect](): string { return this.toString(); }
}
export type ObjectWriteMode = 'create_only' | 'replace';
export class ObjectPutOptions {
#size: number; #mode: ObjectWriteMode; #contentType: string; #metadata: Readonly<Record<string,string>>;
constructor(size: number, mode: ObjectWriteMode, contentType='application/octet-stream', metadata: Record<string,string>={}) {
if (!Number.isSafeInteger(size) || size<0 || !['create_only','replace'].includes(mode)) invalid();
if(size>MAX_OBJECT_PUT_SIZE)throw new StorageError('TooLarge');
header(contentType);
for(const [key,value] of Object.entries(metadata)){if(!/^[a-z0-9][a-z0-9-]*$/.test(key))invalid();header(value);}
this.#size=size;this.#mode=mode;this.#contentType=contentType;this.#metadata=Object.freeze({...metadata});
}
get size(): number{return this.#size;}
get mode(): ObjectWriteMode{return this.#mode;}
get contentType(): string{return this.#contentType;}
get metadata(): Readonly<Record<string,string>>{return this.#metadata;}
toString(): string{return `ObjectPutOptions[size=${this.size}, mode=${this.mode}, metadata=masked]`;}
[inspect](): string{return this.toString();}
}
export class ObjectMetadata {
#key: ObjectKey;#size:number;#contentType?:string;#etag?:string;#lastModified?:Date;#metadata:Readonly<Record<string,string>>;
constructor(key:ObjectKey,size:number,contentType?:string,etag?:string,lastModified?:Date,metadata:Record<string,string>={}){
if(!Number.isSafeInteger(size)||size<0)throw new StorageError('Unavailable');
this.#key=key;this.#size=size;this.#contentType=contentType;this.#etag=etag;this.#lastModified=lastModified&&new Date(lastModified);this.#metadata=Object.freeze({...metadata});
}
get key():ObjectKey{return this.#key;}get size():number{return this.#size;}get contentType():string|undefined{return this.#contentType;}
get etag():string|undefined{return this.#etag;}get lastModified():Date|undefined{return this.#lastModified&&new Date(this.#lastModified);}
get customMetadata():Readonly<Record<string,string>>{return this.#metadata;}
toString():string{return `ObjectMetadata[size=${this.size}, details=masked]`;}[inspect]():string{return this.toString();}
}
export class ObjectReadUrlOptions {
#seconds:number;#disposition?:string;
constructor(seconds=300,disposition?:'inline'|'attachment',filename?:string){
if(!Number.isInteger(seconds)||seconds<=0||seconds>604800||disposition!==undefined&&!['inline','attachment'].includes(disposition))invalid();
if(filename!==undefined){header(filename);if(!disposition||/[\/\\"]/.test(filename))invalid();}
this.#seconds=seconds;this.#disposition=disposition+(filename===undefined?'':`; filename="${filename}"`);
if(disposition===undefined)this.#disposition=undefined;
}
get seconds():number{return this.#seconds;}get contentDisposition():string|undefined{return this.#disposition;}
toString():string{return 'ObjectReadUrlOptions[details=masked]';}[inspect]():string{return this.toString();}
}
export class ObjectReadUrl {
#url:string;#expiresAt:Date;
constructor(url:string,expiresAt:Date){this.#url=url;this.#expiresAt=new Date(expiresAt);}
get url():string{return this.#url;}get expiresAt():Date{return new Date(this.#expiresAt);}
toString():string{return 'ObjectReadUrl[url=masked]';}[inspect]():string{return this.toString();}
}
export type ObjectByteStream = AsyncIterable<Uint8Array>;
export type StorageInvocation = Readonly<{context:UserContext;comment:string}>;
export interface ObjectStorageProvider {
put(invocation:StorageInvocation,key:ObjectKey,content:ObjectByteStream,options:ObjectPutOptions):Promise<ObjectMetadata>;
get(invocation:StorageInvocation,key:ObjectKey):Promise<ObjectContent>;
head(invocation:StorageInvocation,key:ObjectKey):Promise<ObjectMetadata|undefined>;
delete(invocation:StorageInvocation,key:ObjectKey):Promise<void>;
getReadUrl(invocation:StorageInvocation,key:ObjectKey,options:ObjectReadUrlOptions):Promise<ObjectReadUrl>;
}
export type ObjectStorageEvent = Readonly<{operation:string;comment:string;traceId:string;outcome:string;elapsedMs:number}>;
export type ObjectStorageObserver = (event:ObjectStorageEvent)=>void;
function sanitized(error:unknown):StorageError{return error instanceof StorageError?error:new StorageError('Unavailable');}
const safe=(text:unknown):string=>String(text??'').replace(/[\x00-\x1f\x7f-\x9f]/g,' ');
class Receipt {
#ended=false;#started=performance.now();readonly scope;
constructor(private invocation:StorageInvocation,private operation:string){this.scope=startRuntimeOperation(invocation.context.getResource<RuntimeTelemetry>('runtimeTelemetry'),{family:'object_storage',name:operation});}
finish(outcome:string):void{
if(this.#ended)return;this.#ended=true;
if(outcome==='success')this.scope.success();else this.scope.failure(new StorageError(outcome==='cancelled'?'Io':outcome as StorageErrorCode));
const event=Object.freeze({operation:this.operation,comment:safe(this.invocation.comment),traceId:safe(this.invocation.context.getResource('traceId')),outcome,elapsedMs:performance.now()-this.#started});
try{const observer=this.invocation.context.getResource<ObjectStorageObserver>('objectStorageObserver');if(observer)observer(event);else console.info(`ObjectStorage operation=${event.operation} comment=${event.comment} trace=${event.traceId} outcome=${event.outcome} elapsedMs=${event.elapsedMs.toFixed(3)}`);}catch{/* fail open */}
}
}
export class ObjectContent {
#metadata:ObjectMetadata;#stream:ObjectByteStream;#close:()=>Promise<void>;
constructor(metadata:ObjectMetadata,stream:ObjectByteStream,close:()=>Promise<void>){this.#metadata=metadata;this.#stream=stream;this.#close=close;}
get metadata():ObjectMetadata{return this.#metadata;}get stream():ObjectByteStream{return this.#stream;}
close():Promise<void>{return this.#close();}
toString():string{return 'ObjectContent[masked]';}[inspect]():string{return this.toString();}
}
export class ObjectStorage {
#invocation:StorageInvocation;#provider:ObjectStorageProvider;
constructor(context:UserContext,comment:string){
if(typeof comment!=='string'||!comment.trim())invalid();const provider=context.getResource<ObjectStorageProvider>('objectStorageProvider');if(!provider)throw new StorageError('NotConfigured');
this.#invocation=Object.freeze({context,comment});this.#provider=provider;
}
async #call<T>(operation:string,work:()=>Promise<T>):Promise<T>{const receipt=new Receipt(this.#invocation,operation);try{const result=await (receipt.scope.run?receipt.scope.run(work):work());receipt.finish('success');return result;}catch(error){const safeError=sanitized(error);receipt.finish(safeError.code);throw safeError;}}
async put(key:ObjectKey,content:ObjectByteStream,options:ObjectPutOptions):Promise<ObjectMetadata>{
const iterator=content[Symbol.asyncIterator]();let done=false;
const owned:AsyncIterableIterator<Uint8Array>={next:async()=>{const result=await iterator.next();if(result.done)done=true;return result;},return:async()=>{if(!done){done=true;await iterator.return?.();}return {done:true,value:undefined};},[Symbol.asyncIterator](){return this;}};
return this.#call('put',async()=>{try{return await this.#provider.put(this.#invocation,key,owned,options);}finally{await owned.return!();}});
}
async putBytes(key:ObjectKey,bytes:Uint8Array,options:ObjectPutOptions):Promise<ObjectMetadata>{return this.put(key,(async function*(){yield bytes;})(),options);}
async get(key:ObjectKey):Promise<ObjectContent>{
const receipt=new Receipt(this.#invocation,'get');let response:ObjectContent;
try{response=await (receipt.scope.run?receipt.scope.run(()=>this.#provider.get(this.#invocation,key)):this.#provider.get(this.#invocation,key));}catch(error){const safeError=sanitized(error);receipt.finish(safeError.code);throw safeError;}
let closed=false,started=false;
const close=async():Promise<void>=>{if(closed)return;closed=true;try{await response.close();receipt.finish('cancelled');}catch{receipt.finish('Io');throw new StorageError('Io');}};
const stream: ObjectByteStream = {[Symbol.asyncIterator]:async function*(){
if(started||closed)throw new StorageError('Io');started=true;let remaining=response.metadata.size;
try{for await(const bytes of response.stream){if(!(bytes instanceof Uint8Array)||bytes.byteLength>remaining)throw new StorageError('Io');remaining-=bytes.byteLength;yield bytes;}if(remaining!==0)throw new StorageError('Io');receipt.finish('success');}
catch{receipt.finish('Io');throw new StorageError('Io');}finally{await close();}
}};
return new ObjectContent(response.metadata,stream,close);
}
async getBytes(key:ObjectKey,maximumBytes:number):Promise<Uint8Array>{
if(!Number.isSafeInteger(maximumBytes)||maximumBytes<0)invalid();const response=await this.get(key);
try{if(response.metadata.size>maximumBytes)throw new StorageError('TooLarge');const chunks:Uint8Array[]=[];let size=0;for await(const block of response.stream){if(block.byteLength>maximumBytes-size)throw new StorageError('TooLarge');size+=block.byteLength;chunks.push(block);}const result=new Uint8Array(size);let offset=0;for(const block of chunks){result.set(block,offset);offset+=block.byteLength;}return result;}finally{await response.close();}
}
head(key:ObjectKey):Promise<ObjectMetadata|undefined>{return this.#call('head',()=>this.#provider.head(this.#invocation,key));}
async exists(key:ObjectKey):Promise<boolean>{return (await this.head(key))!==undefined;}
delete(key:ObjectKey):Promise<void>{return this.#call('delete',()=>this.#provider.delete(this.#invocation,key));}
getReadUrl(key:ObjectKey,options=new ObjectReadUrlOptions()):Promise<ObjectReadUrl>{return this.#call('read_url',()=>this.#provider.getReadUrl(this.#invocation,key,options));}
}
|