@stacksjs/storage 0.75.72 → 0.75.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { copyFile } from 'node:fs/promises';
2
3
  import type { ChecksumOptions, DirectoryListing, FileContents, GetStreamOptions, ListOptions, MimeTypeOptions, PublicUrlOptions, PutResult, PutStreamOptions, SignedUrlOptions, StatEntry, StorageAdapter, StorageAdapterConfig, TemporaryUrlOptions, Visibility } from '../types';
3
4
  export declare function createBunStorage(config?: StorageAdapterConfig): BunStorageAdapter;
4
5
  export declare class BunStorageAdapter implements StorageAdapter {
@@ -1 +1 @@
1
- import{Buffer}from"node:buffer";import{file,write as bunWrite}from"bun";import{chmod,lstat}from"node:fs/promises";import{dirname,join,relative}from"node:path";import{createDirectoryListing,normalizeExpiryToDate}from"../types";import{createSignedStorageToken}from"../signed-url";export class BunStorageAdapter{root;constructor(config={}){this.root=config.root||process.cwd()}resolvePath(path){const resolved=join(this.root,path),rel=relative(this.root,resolved);if(rel.startsWith("..")||rel.startsWith("../")||rel.startsWith("..\\"))throw Error(`Path traversal detected: '${path}' resolves outside storage root`);return resolved}async write(path,contents){const fullPath=this.resolvePath(path),dir=dirname(fullPath);await this.createDirectory(relative(this.root,dir));if(typeof contents==="string")await bunWrite(fullPath,contents);else if(contents instanceof Buffer)await bunWrite(fullPath,contents);else if(contents instanceof Uint8Array)await bunWrite(fullPath,contents);else{if(typeof contents.getReader!=="function")throw TypeError("[storage/bun] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");const reader=contents.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}const totalLength=chunks.reduce((sum,chunk)=>sum+chunk.length,0),result=new Uint8Array(totalLength);let offset=0;for(const chunk of chunks){result.set(chunk,offset);offset+=chunk.length}await bunWrite(fullPath,result)}const written=file(fullPath);return{path,size:written.size,lastModified:written.lastModified}}async read(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);return await bunFile.arrayBuffer().then((buf)=>new Uint8Array(buf))}async getStream(path,_options){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);return bunFile.stream()}async putStream(path,stream,options){const fullPath=this.resolvePath(path),dir=dirname(fullPath);await this.createDirectory(relative(this.root,dir));try{const body=new Response(stream),writePromise=bunWrite(fullPath,body);if(options?.signal){const abortHandler=()=>{};options.signal.addEventListener("abort",abortHandler,{once:!0});try{await writePromise}finally{options.signal.removeEventListener("abort",abortHandler)}}else await writePromise}catch(err){try{await Bun.$.throws(!1)`rm -f ${fullPath}`}catch{}throw err}const written=file(fullPath);return{path,size:written.size,contentType:options?.contentType,lastModified:written.lastModified}}async readToString(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);return await bunFile.text()}async readToBuffer(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);const arrayBuffer=await bunFile.arrayBuffer();return Buffer.from(arrayBuffer)}async readToUint8Array(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);const arrayBuffer=await bunFile.arrayBuffer();return new Uint8Array(arrayBuffer)}async deleteFile(path){const fullPath=this.resolvePath(path);if(await file(fullPath).exists())await Bun.$.throws(!1)`rm ${fullPath}`}async deleteDirectory(path){const fullPath=this.resolvePath(path);await Bun.$.throws(!1)`rm -rf ${fullPath}`}async createDirectory(path){const fullPath=this.resolvePath(path);await Bun.$.throws(!1)`mkdir -p ${fullPath}`}async moveFile(from,to){const fromPath=this.resolvePath(from),toPath=this.resolvePath(to),toDir=dirname(toPath);await this.createDirectory(relative(this.root,toDir));await Bun.$.throws(!0)`mv ${fromPath} ${toPath}`}async copyFile(from,to){const fromPath=this.resolvePath(from),toPath=this.resolvePath(to),toDir=dirname(toPath);await this.createDirectory(relative(this.root,toDir));await Bun.$.throws(!0)`cp ${fromPath} ${toPath}`}async stat(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);const stats=await Bun.file(fullPath).stat(),isDir=stats.isDirectory();return{path,type:isDir?"directory":"file",visibility:"private",size:isDir?0:stats.size,lastModified:stats.mtime?.getTime()||Date.now(),mimeType:isDir?void 0:bunFile.type}}list(path,options={}){const fullPath=this.resolvePath(path);return this.createAsyncIterator(fullPath,options.deep||!1)}async*createAsyncIterator(dirPath,deep){const entries=[];try{const glob=new Bun.Glob(deep?"**/*":"*");for await(const entry of glob.scan({cwd:dirPath,onlyFiles:!1})){const fullEntryPath=join(dirPath,entry),stats=await Bun.file(fullEntryPath).stat();entries.push({path:relative(this.root,fullEntryPath),type:stats.isDirectory()?"directory":"file"})}}catch(error){return}yield*createDirectoryListing(entries)}async changeVisibility(path,vis){const fullPath=this.resolvePath(path),isDir=(await lstat(fullPath)).isDirectory();await chmod(fullPath,vis==="public"?isDir?493:420:isDir?448:384)}async visibility(path){const fullPath=this.resolvePath(path);return(await lstat(fullPath)).mode&511&4?"public":"private"}async fileExists(path){const fullPath=this.resolvePath(path);return await file(fullPath).exists()}async directoryExists(path){const fullPath=this.resolvePath(path);try{return(await Bun.file(fullPath).stat()).isDirectory()}catch{return!1}}async publicUrl(path,options={}){return`${options.domain||"http://localhost"}/${path}`}async temporaryUrl(path,options){const expiry=normalizeExpiryToDate(options.expiresIn);return`http://localhost/temp/${Buffer.from(`${path}:${expiry.getTime()}`).toString("base64url")}`}async signedUrl(path,options){const token=createSignedStorageToken(path,options);return`${(options.baseUrl||process.env.APP_URL||"http://localhost").replace(/\/$/,"")}/__storage/${encodeURIComponent(path)}?token=${token}`}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);const hasher=new Bun.CryptoHasher(algorithm),arrayBuffer=await bunFile.arrayBuffer();hasher.update(new Uint8Array(arrayBuffer));return hasher.digest("hex")}async mimeType(path,_options={}){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);return bunFile.type||"application/octet-stream"}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}}export function createBunStorage(config={}){return new BunStorageAdapter(config)}
1
+ import{Buffer}from"node:buffer";import{file,write as bunWrite}from"bun";import{chmod,copyFile,lstat,mkdir,rename,rm,unlink}from"node:fs/promises";import{dirname,join,relative}from"node:path";import{createDirectoryListing}from"../types";import{createSignedStorageToken}from"../signed-url";import{publicUrlFor,signedUrlBase,writeAtomically}from"../filesystem-common";export class BunStorageAdapter{root;url;disk;constructor(config={}){this.root=config.root||process.cwd();this.url=config.url;this.disk=config.disk}resolvePath(path){const resolved=join(this.root,path),rel=relative(this.root,resolved);if(rel.startsWith("..")||rel.startsWith("../")||rel.startsWith("..\\"))throw Error(`Path traversal detected: '${path}' resolves outside storage root`);return resolved}async write(path,contents){const fullPath=this.resolvePath(path);if(!(typeof contents==="string"||contents instanceof Uint8Array)){if(typeof contents.getReader!=="function")throw TypeError("[storage/bun] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.")}await writeAtomically(fullPath,async(temporary)=>{if(typeof contents==="string"||contents instanceof Uint8Array)await bunWrite(temporary,contents);else await bunWrite(temporary,new Response(contents))});const written=file(fullPath);return{path,size:written.size,lastModified:written.lastModified}}async read(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);return await bunFile.arrayBuffer().then((buf)=>new Uint8Array(buf))}async getStream(path,_options){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);return bunFile.stream()}async putStream(path,stream,options){const fullPath=this.resolvePath(path),signal=options?.signal;signal?.throwIfAborted();await writeAtomically(fullPath,async(temporary)=>{const source=signal?stream.pipeThrough(new TransformStream,{signal}):stream;await bunWrite(temporary,new Response(source))});const written=file(fullPath);return{path,size:written.size,contentType:options?.contentType,lastModified:written.lastModified}}async readToString(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);return await bunFile.text()}async readToBuffer(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);const arrayBuffer=await bunFile.arrayBuffer();return Buffer.from(arrayBuffer)}async readToUint8Array(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);const arrayBuffer=await bunFile.arrayBuffer();return new Uint8Array(arrayBuffer)}async deleteFile(path){const fullPath=this.resolvePath(path);await unlink(fullPath).catch((error)=>{if(error.code!=="ENOENT")throw error})}async deleteDirectory(path){const fullPath=this.resolvePath(path);await rm(fullPath,{recursive:!0,force:!0})}async createDirectory(path){const fullPath=this.resolvePath(path);await mkdir(fullPath,{recursive:!0})}async moveFile(from,to){const fromPath=this.resolvePath(from),toPath=this.resolvePath(to);await mkdir(dirname(toPath),{recursive:!0});await rename(fromPath,toPath)}async copyFile(from,to){const fromPath=this.resolvePath(from),toPath=this.resolvePath(to);await mkdir(dirname(toPath),{recursive:!0});await copyFile(fromPath,toPath)}async stat(path){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);const stats=await Bun.file(fullPath).stat(),isDir=stats.isDirectory();return{path,type:isDir?"directory":"file",visibility:await this.visibility(path),size:isDir?0:stats.size,lastModified:stats.mtime?.getTime()||Date.now(),mimeType:isDir?void 0:bunFile.type}}list(path,options={}){const fullPath=this.resolvePath(path);return this.createAsyncIterator(fullPath,options.deep||!1)}async*createAsyncIterator(dirPath,deep){const entries=[];try{const glob=new Bun.Glob(deep?"**/*":"*");for await(const entry of glob.scan({cwd:dirPath,onlyFiles:!1})){const fullEntryPath=join(dirPath,entry),stats=await Bun.file(fullEntryPath).stat();entries.push({path:relative(this.root,fullEntryPath),type:stats.isDirectory()?"directory":"file"})}}catch(error){return}yield*createDirectoryListing(entries)}async changeVisibility(path,vis){const fullPath=this.resolvePath(path),isDir=(await lstat(fullPath)).isDirectory();await chmod(fullPath,vis==="public"?isDir?493:420:isDir?448:384)}async visibility(path){const fullPath=this.resolvePath(path);return(await lstat(fullPath)).mode&511&4?"public":"private"}async fileExists(path){const fullPath=this.resolvePath(path);return await file(fullPath).exists()}async directoryExists(path){const fullPath=this.resolvePath(path);try{return(await Bun.file(fullPath).stat()).isDirectory()}catch{return!1}}async publicUrl(path,options={}){return publicUrlFor(path,{domain:options.domain,diskUrl:this.url})}async temporaryUrl(path,options){return await this.signedUrl(path,{expiresIn:options.expiresIn})}async signedUrl(path,options){const token=createSignedStorageToken(path,{...options,disk:options.disk??this.disk});return`${signedUrlBase(options.baseUrl)}/__storage/${encodeURIComponent(path)}?token=${token}`}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);const hasher=new Bun.CryptoHasher(algorithm),arrayBuffer=await bunFile.arrayBuffer();hasher.update(new Uint8Array(arrayBuffer));return hasher.digest("hex")}async mimeType(path,_options={}){const fullPath=this.resolvePath(path),bunFile=file(fullPath);if(!await bunFile.exists())throw Error(`File not found: ${path}`);return bunFile.type||"application/octet-stream"}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}}export function createBunStorage(config={}){return new BunStorageAdapter(config)}
@@ -1 +1 @@
1
- import{Buffer}from"node:buffer";import{createHmac}from"node:crypto";import{createReadStream,createWriteStream}from"node:fs";import{access,chmod,constants,copyFile,lstat,mkdir,readdir,readFile,rename,rm,stat,unlink,writeFile}from"node:fs/promises";import{basename,dirname,join,relative}from"node:path";import{Readable}from"node:stream";import{pipeline}from"node:stream/promises";import{createDirectoryListing,normalizeExpiryToDate}from"../types";import{createSignedStorageToken}from"../signed-url";export class LocalStorageAdapter{root;url;constructor(config={}){this.root=config.root||process.cwd();this.url=config.url?.replace(/\/$/,"")}resolvePath(path){const resolved=join(this.root,path),rel=relative(this.root,resolved);if(rel.startsWith("..")||rel.startsWith("../")||rel.startsWith("..\\"))throw Error(`Path traversal detected: '${path}' resolves outside storage root`);return resolved}async write(path,contents){const fullPath=this.resolvePath(path),dir=dirname(fullPath);await mkdir(dir,{recursive:!0});if(typeof contents==="string")await writeFile(fullPath,contents,"utf8");else if(contents instanceof Buffer)await writeFile(fullPath,contents);else if(contents instanceof Uint8Array)await writeFile(fullPath,contents);else{const writeStream=createWriteStream(fullPath);await pipeline(contents,writeStream)}const st=await stat(fullPath);return{path,size:st.size,lastModified:st.mtimeMs}}async read(path){const fullPath=this.resolvePath(path);return await readFile(fullPath)}async getStream(path,options){const fullPath=this.resolvePath(path);await access(fullPath,constants.R_OK);const nodeStream=createReadStream(fullPath,{signal:options?.signal});return Readable.toWeb(nodeStream)}async putStream(path,stream,options){const fullPath=this.resolvePath(path),dir=dirname(fullPath);await mkdir(dir,{recursive:!0});const nodeReadable=Readable.fromWeb(stream),writeStream=createWriteStream(fullPath);try{await pipeline(nodeReadable,writeStream,{signal:options?.signal})}catch(err){try{await unlink(fullPath)}catch{}throw err}const st=await stat(fullPath);return{path,size:st.size,contentType:options?.contentType,lastModified:st.mtimeMs}}async readToString(path){const fullPath=this.resolvePath(path);return await readFile(fullPath,"utf8")}async readToBuffer(path){const fullPath=this.resolvePath(path);return await readFile(fullPath)}async readToUint8Array(path){const fullPath=this.resolvePath(path),buffer=await readFile(fullPath);return new Uint8Array(buffer)}async deleteFile(path){const fullPath=this.resolvePath(path);await unlink(fullPath)}async deleteDirectory(path){const fullPath=this.resolvePath(path);await rm(fullPath,{recursive:!0,force:!0})}async createDirectory(path){const fullPath=this.resolvePath(path);await mkdir(fullPath,{recursive:!0})}async moveFile(from,to){const fromPath=this.resolvePath(from),toPath=this.resolvePath(to),toDir=dirname(toPath);await mkdir(toDir,{recursive:!0});await rename(fromPath,toPath)}async copyFile(from,to){const fromPath=this.resolvePath(from),toPath=this.resolvePath(to),toDir=dirname(toPath);await mkdir(toDir,{recursive:!0});await copyFile(fromPath,toPath)}async stat(path){const fullPath=this.resolvePath(path),stats=await lstat(fullPath);return{path,type:stats.isDirectory()?"directory":"file",visibility:await this.visibility(path),size:stats.size,lastModified:stats.mtimeMs,mimeType:stats.isFile()?await this.detectMimeType(fullPath):void 0}}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const fullPath=this.resolvePath(path);try{await access(fullPath,constants.R_OK)}catch{return}const entries=await this.readDirectoryRecursive(fullPath,deep);yield*createDirectoryListing(entries)}async readDirectoryRecursive(dirPath,deep){const entries=[];try{const items=await readdir(dirPath,{withFileTypes:!0});for(const item of items){const itemPath=join(dirPath,item.name),relativePath=relative(this.root,itemPath);entries.push({path:relativePath,type:item.isDirectory()?"directory":"file"});if(deep&&item.isDirectory()){const subEntries=await this.readDirectoryRecursive(itemPath,!0);entries.push(...subEntries)}}}catch(error){if(error?.code!=="EACCES"&&error?.code!=="EPERM")throw error}return entries}async changeVisibility(path,vis){const fullPath=this.resolvePath(path),isDir=(await lstat(fullPath)).isDirectory();await chmod(fullPath,vis==="public"?isDir?493:420:isDir?448:384)}async visibility(path){const fullPath=this.resolvePath(path);return(await lstat(fullPath)).mode&511&4?"public":"private"}async fileExists(path){const fullPath=this.resolvePath(path);try{return(await lstat(fullPath)).isFile()}catch{return!1}}async directoryExists(path){const fullPath=this.resolvePath(path);try{return(await lstat(fullPath)).isDirectory()}catch{return!1}}async publicUrl(path,options={}){return`${(options.domain||this.url||process.env.APP_URL||"http://localhost").replace(/\/$/,"")}/${path}`}async temporaryUrl(path,options){const expiry=normalizeExpiryToDate(options.expiresIn),payload=`${path}:${expiry.getTime()}`,appKey=process.env.APP_KEY||"stacks-default-key",signature=createHmac("sha256",appKey).update(payload).digest("hex");return`http://localhost/temp/${Buffer.from(`${payload}:${signature}`).toString("base64url")}`}async signedUrl(path,options){const token=createSignedStorageToken(path,options);return`${(options.baseUrl||process.env.APP_URL||"http://localhost").replace(/\/$/,"")}/__storage/${encodeURIComponent(path)}?token=${token}`}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",fullPath=this.resolvePath(path),content=await readFile(fullPath),hasher=new Bun.CryptoHasher(algorithm);hasher.update(content);return hasher.digest("hex")}async mimeType(path,options={}){const fullPath=this.resolvePath(path);return await this.detectMimeType(fullPath)}async detectMimeType(filePath){const ext=basename(filePath).split(".").pop()?.toLowerCase();return{txt:"text/plain",html:"text/html",css:"text/css",js:"application/javascript",json:"application/json",xml:"application/xml",pdf:"application/pdf",zip:"application/zip",jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",gif:"image/gif",svg:"image/svg+xml",mp4:"video/mp4",mp3:"audio/mpeg",wav:"audio/wav"}[ext||""]||"application/octet-stream"}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}}export function createLocalStorage(config={}){return new LocalStorageAdapter(config)}
1
+ import{Buffer}from"node:buffer";import{createReadStream,createWriteStream}from"node:fs";import{access,chmod,constants,copyFile,lstat,mkdir,readdir,readFile,rename,rm,stat,unlink,writeFile}from"node:fs/promises";import{basename,dirname,join,relative}from"node:path";import{Readable}from"node:stream";import{pipeline}from"node:stream/promises";import{createDirectoryListing}from"../types";import{createSignedStorageToken}from"../signed-url";import{publicUrlFor,signedUrlBase,writeAtomically}from"../filesystem-common";export class LocalStorageAdapter{root;url;disk;constructor(config={}){this.root=config.root||process.cwd();this.url=config.url;this.disk=config.disk}resolvePath(path){const resolved=join(this.root,path),rel=relative(this.root,resolved);if(rel.startsWith("..")||rel.startsWith("../")||rel.startsWith("..\\"))throw Error(`Path traversal detected: '${path}' resolves outside storage root`);return resolved}async write(path,contents){const fullPath=this.resolvePath(path);await writeAtomically(fullPath,async(temporary)=>{if(typeof contents==="string")await writeFile(temporary,contents,"utf8");else if(contents instanceof Uint8Array)await writeFile(temporary,contents);else{const nodeReadable=Readable.fromWeb(contents);await pipeline(nodeReadable,createWriteStream(temporary))}});const st=await stat(fullPath);return{path,size:st.size,lastModified:st.mtimeMs}}async read(path){const fullPath=this.resolvePath(path);return await readFile(fullPath)}async getStream(path,options){const fullPath=this.resolvePath(path);await access(fullPath,constants.R_OK);const nodeStream=createReadStream(fullPath,{signal:options?.signal});return Readable.toWeb(nodeStream)}async putStream(path,stream,options){const fullPath=this.resolvePath(path);await writeAtomically(fullPath,async(temporary)=>{const nodeReadable=Readable.fromWeb(stream);await pipeline(nodeReadable,createWriteStream(temporary),{signal:options?.signal})});const st=await stat(fullPath);return{path,size:st.size,contentType:options?.contentType,lastModified:st.mtimeMs}}async readToString(path){const fullPath=this.resolvePath(path);return await readFile(fullPath,"utf8")}async readToBuffer(path){const fullPath=this.resolvePath(path);return await readFile(fullPath)}async readToUint8Array(path){const fullPath=this.resolvePath(path),buffer=await readFile(fullPath);return new Uint8Array(buffer)}async deleteFile(path){const fullPath=this.resolvePath(path);await unlink(fullPath).catch((error)=>{if(error.code!=="ENOENT")throw error})}async deleteDirectory(path){const fullPath=this.resolvePath(path);await rm(fullPath,{recursive:!0,force:!0})}async createDirectory(path){const fullPath=this.resolvePath(path);await mkdir(fullPath,{recursive:!0})}async moveFile(from,to){const fromPath=this.resolvePath(from),toPath=this.resolvePath(to),toDir=dirname(toPath);await mkdir(toDir,{recursive:!0});await rename(fromPath,toPath)}async copyFile(from,to){const fromPath=this.resolvePath(from),toPath=this.resolvePath(to),toDir=dirname(toPath);await mkdir(toDir,{recursive:!0});await copyFile(fromPath,toPath)}async stat(path){const fullPath=this.resolvePath(path),stats=await lstat(fullPath);return{path,type:stats.isDirectory()?"directory":"file",visibility:await this.visibility(path),size:stats.size,lastModified:stats.mtimeMs,mimeType:stats.isFile()?await this.detectMimeType(fullPath):void 0}}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const fullPath=this.resolvePath(path);try{await access(fullPath,constants.R_OK)}catch{return}const entries=await this.readDirectoryRecursive(fullPath,deep);yield*createDirectoryListing(entries)}async readDirectoryRecursive(dirPath,deep){const entries=[];try{const items=await readdir(dirPath,{withFileTypes:!0});for(const item of items){const itemPath=join(dirPath,item.name),relativePath=relative(this.root,itemPath);entries.push({path:relativePath,type:item.isDirectory()?"directory":"file"});if(deep&&item.isDirectory()){const subEntries=await this.readDirectoryRecursive(itemPath,!0);entries.push(...subEntries)}}}catch(error){if(error?.code!=="EACCES"&&error?.code!=="EPERM")throw error}return entries}async changeVisibility(path,vis){const fullPath=this.resolvePath(path),isDir=(await lstat(fullPath)).isDirectory();await chmod(fullPath,vis==="public"?isDir?493:420:isDir?448:384)}async visibility(path){const fullPath=this.resolvePath(path);return(await lstat(fullPath)).mode&511&4?"public":"private"}async fileExists(path){const fullPath=this.resolvePath(path);try{return(await lstat(fullPath)).isFile()}catch{return!1}}async directoryExists(path){const fullPath=this.resolvePath(path);try{return(await lstat(fullPath)).isDirectory()}catch{return!1}}async publicUrl(path,options={}){return publicUrlFor(path,{domain:options.domain,diskUrl:this.url})}async temporaryUrl(path,options){return await this.signedUrl(path,{expiresIn:options.expiresIn})}async signedUrl(path,options){const token=createSignedStorageToken(path,{...options,disk:options.disk??this.disk});return`${signedUrlBase(options.baseUrl)}/__storage/${encodeURIComponent(path)}?token=${token}`}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",fullPath=this.resolvePath(path),content=await readFile(fullPath),hasher=new Bun.CryptoHasher(algorithm);hasher.update(content);return hasher.digest("hex")}async mimeType(path,options={}){const fullPath=this.resolvePath(path);return await this.detectMimeType(fullPath)}async detectMimeType(filePath){const ext=basename(filePath).split(".").pop()?.toLowerCase();return{txt:"text/plain",html:"text/html",css:"text/css",js:"application/javascript",json:"application/json",xml:"application/xml",pdf:"application/pdf",zip:"application/zip",jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",gif:"image/gif",svg:"image/svg+xml",mp4:"video/mp4",mp3:"audio/mpeg",wav:"audio/wav"}[ext||""]||"application/octet-stream"}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}}export function createLocalStorage(config={}){return new LocalStorageAdapter(config)}
@@ -1 +1 @@
1
- import{Buffer}from"node:buffer";import{mimeFromExtension}from"../mime-from-extension";import{createDirectoryListing,normalizeExpiryToDate}from"../types";export class InMemoryStorageAdapter{files;directories;constructor(){this.files=new Map;this.directories=new Set;this.directories.add("")}normalizePath(path){return path.replace(/^\/+/,"").replace(/\/+$/,"")}getDirectoryPath(path){const parts=path.split("/").filter(Boolean);parts.pop();return parts.join("/")}async contentsToUint8Array(contents){if(typeof contents==="string")return new TextEncoder().encode(contents);else if(contents instanceof Buffer)return new Uint8Array(contents);else if(contents instanceof Uint8Array)return contents;else{if(typeof contents.getReader!=="function")throw TypeError("[storage/memory] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");const reader=contents.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}const totalLength=chunks.reduce((sum,chunk)=>sum+chunk.length,0),result=new Uint8Array(totalLength);let offset=0;for(const chunk of chunks){result.set(chunk,offset);offset+=chunk.length}return result}}async write(path,contents){const normalized=this.normalizePath(path),dirPath=this.getDirectoryPath(normalized);if(dirPath)await this.createDirectory(dirPath);const data=await this.contentsToUint8Array(contents),lastModified=Date.now(),mimeType=this.detectMimeType(normalized);this.files.set(normalized,{contents:data,visibility:"private",mimeType,lastModified});return{path:normalized,size:data.length,contentType:mimeType,lastModified}}async read(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.contents}async getStream(path,_options){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);const bytes=file.contents;return new ReadableStream({start(controller){controller.enqueue(bytes);controller.close()}})}async putStream(path,stream,options){const normalized=this.normalizePath(path),dirPath=this.getDirectoryPath(normalized);if(dirPath)await this.createDirectory(dirPath);const chunks=[],reader=stream.getReader(),abort=options?.signal;try{while(!0){if(abort?.aborted)throw Error("aborted");const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}}finally{try{reader.releaseLock()}catch{}}const totalLength=chunks.reduce((sum,c)=>sum+c.length,0),data=new Uint8Array(totalLength);let offset=0;for(const c of chunks){data.set(c,offset);offset+=c.length}const lastModified=Date.now(),mimeType=options?.contentType??this.detectMimeType(normalized);this.files.set(normalized,{contents:data,visibility:"private",mimeType,lastModified});return{path:normalized,size:data.length,contentType:mimeType,lastModified}}async readToString(path){const data=await this.read(path);return new TextDecoder().decode(data)}async readToBuffer(path){const data=await this.read(path);return Buffer.from(data)}async readToUint8Array(path){return await this.read(path)}async deleteFile(path){const normalized=this.normalizePath(path);if(!this.files.has(normalized))throw Error(`File not found: ${path}`);this.files.delete(normalized)}async deleteDirectory(path){const normalized=this.normalizePath(path),prefix=normalized?`${normalized}/`:"";for(const filePath of this.files.keys())if(filePath===normalized||filePath.startsWith(prefix))this.files.delete(filePath);for(const dir of this.directories)if(dir===normalized||dir.startsWith(prefix))this.directories.delete(dir)}async createDirectory(path){const normalized=this.normalizePath(path);if(!normalized)return;const parts=normalized.split("/").filter(Boolean);let current="";for(const part of parts){current=current?`${current}/${part}`:part;this.directories.add(current)}}async moveFile(from,to){const normalizedFrom=this.normalizePath(from),normalizedTo=this.normalizePath(to),file=this.files.get(normalizedFrom);if(!file)throw Error(`File not found: ${from}`);const toDir=this.getDirectoryPath(normalizedTo);if(toDir)await this.createDirectory(toDir);this.files.set(normalizedTo,{...file,lastModified:Date.now()});this.files.delete(normalizedFrom)}async copyFile(from,to){const normalizedFrom=this.normalizePath(from),normalizedTo=this.normalizePath(to),file=this.files.get(normalizedFrom);if(!file)throw Error(`File not found: ${from}`);const toDir=this.getDirectoryPath(normalizedTo);if(toDir)await this.createDirectory(toDir);const contentsCopy=new Uint8Array(file.contents);this.files.set(normalizedTo,{...file,contents:contentsCopy,lastModified:Date.now()})}async stat(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(file)return{path:normalized,type:"file",visibility:file.visibility,size:file.contents.length,lastModified:file.lastModified,mimeType:file.mimeType};if(this.directories.has(normalized)||normalized==="")return{path:normalized,type:"directory",visibility:"private",size:0,lastModified:Date.now()};throw Error(`Path not found: ${path}`)}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const normalized=this.normalizePath(path),prefix=normalized?`${normalized}/`:"",entries=[],seen=new Set;for(const[filePath]of this.files)if(filePath.startsWith(prefix)||prefix===""){const relativePath=prefix?filePath.slice(prefix.length):filePath;if(!deep){if(relativePath.split("/").filter(Boolean).length===1)entries.push({path:filePath,type:"file"})}else entries.push({path:filePath,type:"file"})}for(const dir of this.directories)if((dir.startsWith(prefix)||prefix==="")&&dir!==normalized){const relativePath=prefix?dir.slice(prefix.length):dir;if(!deep){if(relativePath.split("/").filter(Boolean).length===1&&!seen.has(dir)){entries.push({path:dir,type:"directory"});seen.add(dir)}}else if(!seen.has(dir)){entries.push({path:dir,type:"directory"});seen.add(dir)}}yield*createDirectoryListing(entries)}async changeVisibility(path,visibility){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);file.visibility=visibility}async visibility(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.visibility}async fileExists(path){const normalized=this.normalizePath(path);return this.files.has(normalized)}async directoryExists(path){const normalized=this.normalizePath(path);return this.directories.has(normalized)||normalized===""}async publicUrl(path,options={}){return`${(options.domain||process.env.APP_URL||"http://localhost").replace(/\/$/,"")}/${this.normalizePath(path)}`}async temporaryUrl(path,options){const expiry=normalizeExpiryToDate(options.expiresIn);return`http://localhost/temp/${Buffer.from(`${path}:${expiry.getTime()}`).toString("base64url")}`}async signedUrl(_path,_options){throw Error("[storage/memory] signedUrl is not supported on the in-memory adapter - switch to local or s3 disk for signed URL generation.")}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",data=await this.readToUint8Array(path),hasher=new Bun.CryptoHasher(algorithm);hasher.update(data);return hasher.digest("hex")}async mimeType(path,_options={}){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.mimeType}detectMimeType(path){return mimeFromExtension(path)}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}clear(){this.files.clear();this.directories.clear();this.directories.add("")}}export function createMemoryStorage(){return new InMemoryStorageAdapter}
1
+ import{Buffer}from"node:buffer";import{mimeFromExtension}from"../mime-from-extension";import{createDirectoryListing,normalizeExpiryToDate}from"../types";export class InMemoryStorageAdapter{files;directories;constructor(){this.files=new Map;this.directories=new Set;this.directories.add("")}normalizePath(path){return path.replace(/^\/+/,"").replace(/\/+$/,"")}getDirectoryPath(path){const parts=path.split("/").filter(Boolean);parts.pop();return parts.join("/")}async contentsToUint8Array(contents){if(typeof contents==="string")return new TextEncoder().encode(contents);else if(contents instanceof Buffer)return new Uint8Array(contents);else if(contents instanceof Uint8Array)return contents;else{if(typeof contents.getReader!=="function")throw TypeError("[storage/memory] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");const reader=contents.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}const totalLength=chunks.reduce((sum,chunk)=>sum+chunk.length,0),result=new Uint8Array(totalLength);let offset=0;for(const chunk of chunks){result.set(chunk,offset);offset+=chunk.length}return result}}async write(path,contents){const normalized=this.normalizePath(path),dirPath=this.getDirectoryPath(normalized);if(dirPath)await this.createDirectory(dirPath);const data=await this.contentsToUint8Array(contents),lastModified=Date.now(),mimeType=this.detectMimeType(normalized);this.files.set(normalized,{contents:data,visibility:"private",mimeType,lastModified});return{path:normalized,size:data.length,contentType:mimeType,lastModified}}async read(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.contents}async getStream(path,_options){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);const bytes=file.contents;return new ReadableStream({start(controller){controller.enqueue(bytes);controller.close()}})}async putStream(path,stream,options){const normalized=this.normalizePath(path),dirPath=this.getDirectoryPath(normalized);if(dirPath)await this.createDirectory(dirPath);const chunks=[],reader=stream.getReader(),abort=options?.signal;try{while(!0){if(abort?.aborted)throw Error("aborted");const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}}finally{try{reader.releaseLock()}catch{}}const totalLength=chunks.reduce((sum,c)=>sum+c.length,0),data=new Uint8Array(totalLength);let offset=0;for(const c of chunks){data.set(c,offset);offset+=c.length}const lastModified=Date.now(),mimeType=options?.contentType??this.detectMimeType(normalized);this.files.set(normalized,{contents:data,visibility:"private",mimeType,lastModified});return{path:normalized,size:data.length,contentType:mimeType,lastModified}}async readToString(path){const data=await this.read(path);return new TextDecoder().decode(data)}async readToBuffer(path){const data=await this.read(path);return Buffer.from(data)}async readToUint8Array(path){return await this.read(path)}async deleteFile(path){this.files.delete(this.normalizePath(path))}async deleteDirectory(path){const normalized=this.normalizePath(path),prefix=normalized?`${normalized}/`:"";for(const filePath of this.files.keys())if(filePath===normalized||filePath.startsWith(prefix))this.files.delete(filePath);for(const dir of this.directories)if(dir===normalized||dir.startsWith(prefix))this.directories.delete(dir)}async createDirectory(path){const normalized=this.normalizePath(path);if(!normalized)return;const parts=normalized.split("/").filter(Boolean);let current="";for(const part of parts){current=current?`${current}/${part}`:part;this.directories.add(current)}}async moveFile(from,to){const normalizedFrom=this.normalizePath(from),normalizedTo=this.normalizePath(to),file=this.files.get(normalizedFrom);if(!file)throw Error(`File not found: ${from}`);const toDir=this.getDirectoryPath(normalizedTo);if(toDir)await this.createDirectory(toDir);this.files.set(normalizedTo,{...file,lastModified:Date.now()});this.files.delete(normalizedFrom)}async copyFile(from,to){const normalizedFrom=this.normalizePath(from),normalizedTo=this.normalizePath(to),file=this.files.get(normalizedFrom);if(!file)throw Error(`File not found: ${from}`);const toDir=this.getDirectoryPath(normalizedTo);if(toDir)await this.createDirectory(toDir);const contentsCopy=new Uint8Array(file.contents);this.files.set(normalizedTo,{...file,contents:contentsCopy,lastModified:Date.now()})}async stat(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(file)return{path:normalized,type:"file",visibility:file.visibility,size:file.contents.length,lastModified:file.lastModified,mimeType:file.mimeType};if(this.directories.has(normalized)||normalized==="")return{path:normalized,type:"directory",visibility:"private",size:0,lastModified:Date.now()};throw Error(`Path not found: ${path}`)}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const normalized=this.normalizePath(path),prefix=normalized?`${normalized}/`:"",entries=[],seen=new Set;for(const[filePath]of this.files)if(filePath.startsWith(prefix)||prefix===""){const relativePath=prefix?filePath.slice(prefix.length):filePath;if(!deep){if(relativePath.split("/").filter(Boolean).length===1)entries.push({path:filePath,type:"file"})}else entries.push({path:filePath,type:"file"})}for(const dir of this.directories)if((dir.startsWith(prefix)||prefix==="")&&dir!==normalized){const relativePath=prefix?dir.slice(prefix.length):dir;if(!deep){if(relativePath.split("/").filter(Boolean).length===1&&!seen.has(dir)){entries.push({path:dir,type:"directory"});seen.add(dir)}}else if(!seen.has(dir)){entries.push({path:dir,type:"directory"});seen.add(dir)}}yield*createDirectoryListing(entries)}async changeVisibility(path,visibility){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);file.visibility=visibility}async visibility(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.visibility}async fileExists(path){const normalized=this.normalizePath(path);return this.files.has(normalized)}async directoryExists(path){const normalized=this.normalizePath(path);return this.directories.has(normalized)||normalized===""}async publicUrl(path,options={}){return`${(options.domain||process.env.APP_URL||"http://localhost").replace(/\/$/,"")}/${this.normalizePath(path)}`}async temporaryUrl(path,options){const expiry=normalizeExpiryToDate(options.expiresIn);return`http://localhost/temp/${Buffer.from(`${path}:${expiry.getTime()}`).toString("base64url")}`}async signedUrl(_path,_options){throw Error("[storage/memory] signedUrl is not supported on the in-memory adapter - switch to local or s3 disk for signed URL generation.")}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",data=await this.readToUint8Array(path),hasher=new Bun.CryptoHasher(algorithm);hasher.update(data);return hasher.digest("hex")}async mimeType(path,_options={}){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.mimeType}detectMimeType(path){return mimeFromExtension(path)}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}clear(){this.files.clear();this.directories.clear();this.directories.add("")}}export function createMemoryStorage(){return new InMemoryStorageAdapter}
@@ -1 +1 @@
1
- import{Buffer}from"node:buffer";import{extensionForContentType,mimeFromExtension}from"../mime-from-extension";import{normalizeExpiryToMilliseconds}from"../types";import{sanitizePresignedDir,sanitizePresignedFilename}from"../path-sanitize";import{signS3PresignedPost}from"../s3-presigned-post";import process from"node:process";const S3_MIN_PART_SIZE=5242880,S3_MAX_PART_SIZE=5368709120;function clampPartSize(requested){if(!Number.isFinite(requested))return S3_MIN_PART_SIZE;return Math.max(S3_MIN_PART_SIZE,Math.min(Math.floor(requested),S3_MAX_PART_SIZE))}class ChunkBuffer{chunks=[];total=0;constructor(_partSize){}get length(){return this.total}push(c){this.chunks.push(c);this.total+=c.length}take(n){const out=new Uint8Array(n);let written=0;while(written<n&&this.chunks.length>0){const head=this.chunks[0],need=n-written;if(head.length<=need){out.set(head,written);written+=head.length;this.chunks.shift()}else{out.set(head.subarray(0,need),written);this.chunks[0]=head.subarray(need);written+=need}}this.total-=n;return out}flush(){const out=new Uint8Array(this.total);let off=0;for(const c of this.chunks){out.set(c,off);off+=c.length}this.chunks=[];this.total=0;return out}}async function isSettled(p){const sentinel=Symbol("pending");return await Promise.race([p.then(()=>"settled",()=>"settled"),Promise.resolve(sentinel)])!==sentinel}export function resolveS3ClientOptions(config){const options={};if(config.endpoint)options.endpoint=config.endpoint.replace(/^https:\/\//i,"").replace(/\/+$/,"");if(config.usePathStyleEndpoint)options.forcePathStyle=!0;if(config.credentials)options.credentials=config.credentials;return Object.keys(options).length>0?options:void 0}export class S3StorageAdapter{_client;_clientPromise=null;bucket;prefix;region;endpoint;usePathStyleEndpoint;publicBaseUrl;credentials;constructor(client,config){this._client=client;this.bucket=config.bucket||"";this.prefix=config.prefix||"";this.region=config.region||"us-east-1";this.credentials=config.credentials;this.endpoint=config.endpoint;this.usePathStyleEndpoint=config.usePathStyleEndpoint;this.publicBaseUrl=config.url?.replace(/\/+$/,"");if(!this.bucket)throw Error("S3 bucket name is required")}async getClient(){if(this._client)return this._client;if(!this._clientPromise)this._clientPromise=import("@stacksjs/ts-cloud").then((cloud)=>{this._client=new cloud.S3Client(this.region,void 0,resolveS3ClientOptions({endpoint:this.endpoint,usePathStyleEndpoint:this.usePathStyleEndpoint,credentials:this.credentials}));return this._client});return this._clientPromise}resolveCredentials(){if(this.credentials?.accessKeyId&&this.credentials.secretAccessKey)return this.credentials;const accessKeyId=process.env.AWS_ACCESS_KEY_ID,secretAccessKey=process.env.AWS_SECRET_ACCESS_KEY,sessionToken=process.env.AWS_SESSION_TOKEN;if(!accessKeyId||!secretAccessKey)throw Error("[storage/s3] presignedUploadPolicy requires AWS credentials - pass them via S3DiskConfig.credentials or set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY.");return{accessKeyId,secretAccessKey,sessionToken}}prefixPath(path){if(!this.prefix)return path;return`${this.prefix}/${path}`.replace(/\/+/g,"/")}stripPrefix(path){if(!this.prefix)return path;const prefixWithSlash=`${this.prefix}/`;return path.startsWith(prefixWithSlash)?path.slice(prefixWithSlash.length):path}async contentsToBuffer(contents){if(typeof contents==="string")return Buffer.from(contents,"utf8");else if(contents instanceof Buffer)return contents;else if(contents instanceof Uint8Array)return Buffer.from(contents);else{if(typeof contents.getReader!=="function")throw TypeError("[storage/s3] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");const reader=contents.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}return Buffer.concat(chunks.map((c)=>Buffer.from(c)))}}async write(path,contents){const key=this.prefixPath(path),body=await this.contentsToBuffer(contents),contentType=this.detectMimeType(path);await(await this.getClient()).putObject({bucket:this.bucket,key,body,contentType});return{path,size:body.length,contentType,lastModified:Date.now()}}async read(path){const key=this.prefixPath(path),response=await(await this.getClient()).getObjectBytes(this.bucket,key);if(!response?.body)throw Error(`Failed to read file: ${path}`);return Buffer.from(response.body)}async getStream(path,_options){const key=this.prefixPath(path),buf=await(await this.getClient()).getObjectBuffer(this.bucket,key);if(!buf)throw Error(`Failed to read file: ${path}`);const bytes=new Uint8Array(buf);return new ReadableStream({start(controller){controller.enqueue(bytes);controller.close()}})}async putStream(path,stream,options){const key=this.prefixPath(path),contentType=options?.contentType??this.detectMimeType(path),partSize=clampPartSize(options?.partSize??5242880),concurrency=Math.max(1,Math.min(options?.concurrency??4,100)),maxRetries=Math.max(0,options?.maxRetries??3),signal=options?.signal,reader=stream.getReader();let firstChunk=null,firstDone=!1;{const buf=new ChunkBuffer(partSize);while(!firstDone&&buf.length<partSize){if(signal?.aborted){try{reader.releaseLock()}catch{}throw Error("aborted")}const{value,done}=await reader.read();if(done){firstDone=!0;break}if(value)buf.push(value)}firstChunk=buf.flush()}if(firstDone){try{reader.releaseLock()}catch{}await(await this.getClient()).putObject({bucket:this.bucket,key,body:Buffer.from(firstChunk),contentType});return{path,size:firstChunk.length,contentType,lastModified:Date.now()}}const{UploadId:uploadId}=await(await this.getClient()).createMultipartUpload(this.bucket,key,{contentType}),completedParts=[];let totalBytes=0,partNumber=1;const inflight=[],uploadOne=async(body,n)=>{let attempt=0;while(!0){if(signal?.aborted)throw Error("aborted");try{const{ETag}=await(await this.getClient()).uploadPart(this.bucket,key,uploadId,n,Buffer.from(body));completedParts.push({PartNumber:n,ETag});totalBytes+=body.length;return}catch(err){if(attempt>=maxRetries)throw err;attempt+=1}}};try{inflight.push(uploadOne(firstChunk,partNumber++));firstChunk=null;const buf=new ChunkBuffer(partSize);while(!0){if(signal?.aborted)throw Error("aborted");const{value,done}=await reader.read();if(done)break;if(value)buf.push(value);while(buf.length>=partSize){const part=buf.take(partSize);if(inflight.length>=concurrency){await Promise.race(inflight.map((p,i)=>p.then(()=>i)));for(let i=inflight.length-1;i>=0;i--)if(await isSettled(inflight[i]))inflight.splice(i,1)}inflight.push(uploadOne(part,partNumber++))}}try{reader.releaseLock()}catch{}const tail=buf.flush();if(tail.length>0)inflight.push(uploadOne(tail,partNumber++));await Promise.all(inflight);completedParts.sort((a,b)=>a.PartNumber-b.PartNumber);await(await this.getClient()).completeMultipartUpload(this.bucket,key,uploadId,completedParts);return{path,size:totalBytes,contentType,lastModified:Date.now()}}catch(err){try{await(await this.getClient()).abortMultipartUpload(this.bucket,key,uploadId)}catch{}throw err}}async readToString(path){const key=this.prefixPath(path),response=await(await this.getClient()).getObject(this.bucket,key);if(!response)throw Error(`Failed to read file: ${path}`);return response}async readToBuffer(path){return await this.read(path)}async readToUint8Array(path){const buffer=await this.readToBuffer(path);return new Uint8Array(buffer)}async deleteFile(path){const key=this.prefixPath(path);await(await this.getClient()).deleteObject(this.bucket,key)}async deleteDirectory(path){const prefix=this.prefixPath(path),normalizedPrefix=prefix.endsWith("/")?prefix:`${prefix}/`,keys=(await(await this.getClient()).listAllObjects({bucket:this.bucket,prefix:normalizedPrefix})).map((obj)=>obj.Key).filter((k)=>typeof k==="string");if(keys.length===0)return;await(await this.getClient()).deleteObjects(this.bucket,keys)}async createDirectory(_path){}async moveFile(from,to){await this.copyFile(from,to);await this.deleteFile(from)}async copyFile(from,to){const fromKey=this.prefixPath(from),toKey=this.prefixPath(to);await(await this.getClient()).copyObject({sourceBucket:this.bucket,sourceKey:fromKey,destinationBucket:this.bucket,destinationKey:toKey})}async stat(path){const key=this.prefixPath(path),result=await(await this.getClient()).headObject(this.bucket,key);if(!result)throw Error(`File not found: ${path}`);return{path,type:"file",visibility:"private",size:result.ContentLength||0,lastModified:result.LastModified?new Date(result.LastModified).getTime():Date.now(),mimeType:result.ContentType}}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const prefix=this.prefixPath(path),normalizedPrefix=prefix?`${prefix}/`:void 0;if(deep){const objects=await(await this.getClient()).listAllObjects({bucket:this.bucket,prefix:normalizedPrefix});for(const obj of objects)yield{path:this.stripPrefix(obj.Key),type:"file"}}else{let continuationToken;do{const result=await(await this.getClient()).listObjects({bucket:this.bucket,prefix:normalizedPrefix,continuationToken});for(const obj of result.objects||[])yield{path:this.stripPrefix(obj.Key),type:"file"};continuationToken=result.nextContinuationToken}while(continuationToken)}}async changeVisibility(path,vis){const key=this.prefixPath(path),acl=vis==="public"?"public-read":"private";await(await this.getClient()).putObjectAcl(this.bucket,key,acl)}async visibility(path){const key=this.prefixPath(path),grant=(await(await this.getClient()).getObjectAcl(this.bucket,key))?.AccessControlList?.Grant;return(grant===void 0?[]:Array.isArray(grant)?grant:[grant]).some((g)=>g.Grantee?.URI==="http://acs.amazonaws.com/groups/global/AllUsers"&&(g.Permission==="READ"||g.Permission==="FULL_CONTROL"))?"public":"private"}async fileExists(path){const key=this.prefixPath(path);try{return!!await(await this.getClient()).headObject(this.bucket,key)}catch(error){if(!error.message?.includes("404")&&!error.message?.includes("NoSuchKey")&&!error.message?.includes("NotFound"))console.debug(`[s3] Unexpected error checking file existence for ${path}: ${error.message}`);return!1}}async directoryExists(path){const prefix=this.prefixPath(path);return((await(await this.getClient()).listObjects({bucket:this.bucket,prefix:`${prefix}/`,maxKeys:1})).objects||[]).length>0}async publicUrl(path,options={}){const key=this.prefixPath(path),base=options.domain?.replace(/\/+$/,"")??this.publicBaseUrl;if(base)return`${base}/${key}`;if(this.endpoint)return`${this.endpointBaseUrl()}/${key}`;return`https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`}endpointBaseUrl(){const endpoint=this.endpoint.replace(/\/+$/,""),withScheme=/^https?:\/\//.test(endpoint)?endpoint:`https://${endpoint}`;if(this.usePathStyleEndpoint)return`${withScheme}/${this.bucket}`;const url=new URL(withScheme);url.hostname=`${this.bucket}.${url.hostname}`;return url.origin}async temporaryUrl(path,options){const key=this.prefixPath(path),expiresIn=Math.floor(normalizeExpiryToMilliseconds(options.expiresIn)/1000),MIN_EXPIRY=60,MAX_EXPIRY=604800;if(!Number.isFinite(expiresIn)||expiresIn<MIN_EXPIRY||expiresIn>MAX_EXPIRY)throw RangeError(`[storage/s3] temporaryUrl expiresIn must be between 60s and 7 days (got ${expiresIn}s)`);return await(await this.getClient()).getSignedUrl({bucket:this.bucket,key,expiresIn,operation:"getObject"})}async signedUrl(path,options){return this.temporaryUrl(path,{expiresIn:options.expiresIn})}async presignedUploadUrl(options){if(!options.contentType)throw Error("[storage/s3] presignedUploadUrl requires `contentType` - S3 signs against the exact header.");const expiresIn=Math.floor(options.expiresIn),MIN_EXPIRY=60,MAX_EXPIRY=604800;if(!Number.isFinite(expiresIn)||expiresIn<MIN_EXPIRY||expiresIn>MAX_EXPIRY)throw RangeError(`[storage/s3] presignedUploadUrl expiresIn must be between 60s and 7 days (got ${expiresIn}s)`);const safeDir=sanitizePresignedDir(options.dir),safeFilename=options.filename!==void 0?sanitizePresignedFilename(options.filename):`${crypto.randomUUID().replace(/-/g,"")}${this.extensionForContentType(options.contentType)}`,path=safeDir?`${safeDir}/${safeFilename}`:safeFilename,key=this.prefixPath(path);return{url:await(await this.getClient()).getSignedUrl({bucket:this.bucket,key,expiresIn,operation:"putObject"}),path,key,contentType:options.contentType,maxBytes:options.maxBytes}}async presignedUploadPolicy(options){const credentials=this.resolveCredentials(),scopedKey=typeof options.key==="string"?this.prefixPath(options.key):{startsWith:this.prefixPath(options.key.startsWith)};return signS3PresignedPost({bucket:this.bucket,region:this.region,credentials,key:scopedKey,contentType:options.contentType,contentLengthRange:options.contentLengthRange,acl:options.acl,expiresIn:options.expiresIn,fields:options.fields})}extensionForContentType(contentType){return extensionForContentType(contentType)}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",content=await this.readToUint8Array(path),hasher=new Bun.CryptoHasher(algorithm);hasher.update(content);return hasher.digest("hex")}async mimeType(path,_options={}){return(await this.stat(path)).mimeType||this.detectMimeType(path)}detectMimeType(path){return mimeFromExtension(path)}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}}export function createS3Storage(client,config){return new S3StorageAdapter(client,config)}
1
+ import{Buffer}from"node:buffer";import{extensionForContentType,mimeFromExtension}from"../mime-from-extension";import{normalizeExpiryToMilliseconds}from"../types";import{sanitizePresignedDir,sanitizePresignedFilename}from"../path-sanitize";import{signS3PresignedPost}from"../s3-presigned-post";import process from"node:process";const S3_MIN_PART_SIZE=5242880,S3_MAX_PART_SIZE=5368709120;function clampPartSize(requested){if(!Number.isFinite(requested))return S3_MIN_PART_SIZE;return Math.max(S3_MIN_PART_SIZE,Math.min(Math.floor(requested),S3_MAX_PART_SIZE))}class ChunkBuffer{chunks=[];total=0;constructor(_partSize){}get length(){return this.total}push(c){this.chunks.push(c);this.total+=c.length}take(n){const out=new Uint8Array(n);let written=0;while(written<n&&this.chunks.length>0){const head=this.chunks[0],need=n-written;if(head.length<=need){out.set(head,written);written+=head.length;this.chunks.shift()}else{out.set(head.subarray(0,need),written);this.chunks[0]=head.subarray(need);written+=need}}this.total-=n;return out}flush(){const out=new Uint8Array(this.total);let off=0;for(const c of this.chunks){out.set(c,off);off+=c.length}this.chunks=[];this.total=0;return out}}async function isSettled(p){const sentinel=Symbol("pending");return await Promise.race([p.then(()=>"settled",()=>"settled"),Promise.resolve(sentinel)])!==sentinel}export function resolveS3ClientOptions(config){const options={};if(config.endpoint)options.endpoint=config.endpoint.replace(/^https:\/\//i,"").replace(/\/+$/,"");if(config.usePathStyleEndpoint)options.forcePathStyle=!0;if(config.credentials)options.credentials=config.credentials;return Object.keys(options).length>0?options:void 0}export class S3StorageAdapter{_client;_clientPromise=null;bucket;prefix;region;endpoint;usePathStyleEndpoint;publicBaseUrl;credentials;constructor(client,config){this._client=client;this.bucket=config.bucket||"";this.prefix=config.prefix||"";this.region=config.region||"us-east-1";this.credentials=config.credentials;this.endpoint=config.endpoint;this.usePathStyleEndpoint=config.usePathStyleEndpoint;this.publicBaseUrl=config.url?.replace(/\/+$/,"");if(!this.bucket)throw Error("S3 bucket name is required")}async getClient(){if(this._client)return this._client;if(!this._clientPromise)this._clientPromise=import("@stacksjs/ts-cloud").then((cloud)=>{this._client=new cloud.S3Client(this.region,void 0,resolveS3ClientOptions({endpoint:this.endpoint,usePathStyleEndpoint:this.usePathStyleEndpoint,credentials:this.credentials}));return this._client});return this._clientPromise}resolveCredentials(){if(this.credentials?.accessKeyId&&this.credentials.secretAccessKey)return this.credentials;const accessKeyId=process.env.AWS_ACCESS_KEY_ID,secretAccessKey=process.env.AWS_SECRET_ACCESS_KEY,sessionToken=process.env.AWS_SESSION_TOKEN;if(!accessKeyId||!secretAccessKey)throw Error("[storage/s3] presignedUploadPolicy requires AWS credentials - pass them via S3DiskConfig.credentials or set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY.");return{accessKeyId,secretAccessKey,sessionToken}}prefixPath(path){if(!this.prefix)return path;return`${this.prefix}/${path}`.replace(/\/+/g,"/")}stripPrefix(path){if(!this.prefix)return path;const prefixWithSlash=`${this.prefix}/`;return path.startsWith(prefixWithSlash)?path.slice(prefixWithSlash.length):path}async contentsToBuffer(contents){if(typeof contents==="string")return Buffer.from(contents,"utf8");else if(contents instanceof Buffer)return contents;else if(contents instanceof Uint8Array)return Buffer.from(contents);else{if(typeof contents.getReader!=="function")throw TypeError("[storage/s3] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");const reader=contents.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}return Buffer.concat(chunks.map((c)=>Buffer.from(c)))}}async write(path,contents){const key=this.prefixPath(path),body=await this.contentsToBuffer(contents),contentType=this.detectMimeType(path);await(await this.getClient()).putObject({bucket:this.bucket,key,body,contentType});return{path,size:body.length,contentType,lastModified:Date.now()}}async read(path){const key=this.prefixPath(path),response=await(await this.getClient()).getObjectBytes(this.bucket,key);if(!response?.body)throw Error(`Failed to read file: ${path}`);return Buffer.from(response.body)}async getStream(path,_options){const key=this.prefixPath(path),buf=await(await this.getClient()).getObjectBuffer(this.bucket,key);if(!buf)throw Error(`Failed to read file: ${path}`);const bytes=new Uint8Array(buf);return new ReadableStream({start(controller){controller.enqueue(bytes);controller.close()}})}async putStream(path,stream,options){const key=this.prefixPath(path),contentType=options?.contentType??this.detectMimeType(path),partSize=clampPartSize(options?.partSize??5242880),concurrency=Math.max(1,Math.min(options?.concurrency??4,100)),maxRetries=Math.max(0,options?.maxRetries??3),signal=options?.signal,reader=stream.getReader();let firstChunk=null,firstDone=!1;{const buf=new ChunkBuffer(partSize);while(!firstDone&&buf.length<partSize){if(signal?.aborted){try{reader.releaseLock()}catch{}throw Error("aborted")}const{value,done}=await reader.read();if(done){firstDone=!0;break}if(value)buf.push(value)}firstChunk=buf.flush()}if(firstDone){try{reader.releaseLock()}catch{}await(await this.getClient()).putObject({bucket:this.bucket,key,body:Buffer.from(firstChunk),contentType});return{path,size:firstChunk.length,contentType,lastModified:Date.now()}}const{UploadId:uploadId}=await(await this.getClient()).createMultipartUpload(this.bucket,key,{contentType}),completedParts=[];let totalBytes=0,partNumber=1;const inflight=[],uploadOne=async(body,n)=>{let attempt=0;while(!0){if(signal?.aborted)throw Error("aborted");try{const{ETag}=await(await this.getClient()).uploadPart(this.bucket,key,uploadId,n,Buffer.from(body));completedParts.push({PartNumber:n,ETag});totalBytes+=body.length;return}catch(err){if(attempt>=maxRetries)throw err;attempt+=1}}};try{inflight.push(uploadOne(firstChunk,partNumber++));firstChunk=null;const buf=new ChunkBuffer(partSize);while(!0){if(signal?.aborted)throw Error("aborted");const{value,done}=await reader.read();if(done)break;if(value)buf.push(value);while(buf.length>=partSize){const part=buf.take(partSize);if(inflight.length>=concurrency){await Promise.race(inflight.map((p,i)=>p.then(()=>i)));for(let i=inflight.length-1;i>=0;i--)if(await isSettled(inflight[i]))inflight.splice(i,1)}inflight.push(uploadOne(part,partNumber++))}}try{reader.releaseLock()}catch{}const tail=buf.flush();if(tail.length>0)inflight.push(uploadOne(tail,partNumber++));await Promise.all(inflight);completedParts.sort((a,b)=>a.PartNumber-b.PartNumber);await(await this.getClient()).completeMultipartUpload(this.bucket,key,uploadId,completedParts);return{path,size:totalBytes,contentType,lastModified:Date.now()}}catch(err){try{await(await this.getClient()).abortMultipartUpload(this.bucket,key,uploadId)}catch{}throw err}}async readToString(path){const key=this.prefixPath(path),response=await(await this.getClient()).getObject(this.bucket,key);if(!response)throw Error(`Failed to read file: ${path}`);return response}async readToBuffer(path){return await this.read(path)}async readToUint8Array(path){const buffer=await this.readToBuffer(path);return new Uint8Array(buffer)}async deleteFile(path){const key=this.prefixPath(path);await(await this.getClient()).deleteObject(this.bucket,key)}async deleteDirectory(path){const prefix=this.prefixPath(path),normalizedPrefix=prefix.endsWith("/")?prefix:`${prefix}/`,keys=(await(await this.getClient()).listAllObjects({bucket:this.bucket,prefix:normalizedPrefix})).map((obj)=>obj.Key).filter((k)=>typeof k==="string");if(keys.length===0)return;await(await this.getClient()).deleteObjects(this.bucket,keys)}async createDirectory(_path){}async moveFile(from,to){await this.copyFile(from,to);await this.deleteFile(from)}async copyFile(from,to){const fromKey=this.prefixPath(from),toKey=this.prefixPath(to);await(await this.getClient()).copyObject({sourceBucket:this.bucket,sourceKey:fromKey,destinationBucket:this.bucket,destinationKey:toKey})}async stat(path){const key=this.prefixPath(path),result=await(await this.getClient()).headObject(this.bucket,key);if(!result)throw Error(`File not found: ${path}`);return{path,type:"file",visibility:"private",size:result.ContentLength||0,lastModified:result.LastModified?new Date(result.LastModified).getTime():Date.now(),mimeType:result.ContentType}}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const prefix=this.prefixPath(path),normalizedPrefix=prefix?`${prefix}/`:void 0;if(deep){const objects=await(await this.getClient()).listAllObjects({bucket:this.bucket,prefix:normalizedPrefix});for(const obj of objects)yield{path:this.stripPrefix(obj.Key),type:"file"}}else{let continuationToken;do{const result=await(await this.getClient()).listObjects({bucket:this.bucket,prefix:normalizedPrefix,delimiter:"/",continuationToken});for(const obj of result.objects||[]){if(obj.Key===normalizedPrefix)continue;yield{path:this.stripPrefix(obj.Key),type:"file"}}for(const folder of result.commonPrefixes||[])yield{path:this.stripPrefix(folder.replace(/\/$/,"")),type:"directory"};continuationToken=result.nextContinuationToken}while(continuationToken)}}async changeVisibility(path,vis){const key=this.prefixPath(path),acl=vis==="public"?"public-read":"private";await(await this.getClient()).putObjectAcl(this.bucket,key,acl)}async visibility(path){const key=this.prefixPath(path),grant=(await(await this.getClient()).getObjectAcl(this.bucket,key))?.AccessControlList?.Grant;return(grant===void 0?[]:Array.isArray(grant)?grant:[grant]).some((g)=>g.Grantee?.URI==="http://acs.amazonaws.com/groups/global/AllUsers"&&(g.Permission==="READ"||g.Permission==="FULL_CONTROL"))?"public":"private"}async fileExists(path){const key=this.prefixPath(path);try{return!!await(await this.getClient()).headObject(this.bucket,key)}catch(error){if(!error.message?.includes("404")&&!error.message?.includes("NoSuchKey")&&!error.message?.includes("NotFound"))console.debug(`[s3] Unexpected error checking file existence for ${path}: ${error.message}`);return!1}}async directoryExists(path){const prefix=this.prefixPath(path);return((await(await this.getClient()).listObjects({bucket:this.bucket,prefix:`${prefix}/`,maxKeys:1})).objects||[]).length>0}async publicUrl(path,options={}){const key=this.prefixPath(path),base=options.domain?.replace(/\/+$/,"")??this.publicBaseUrl;if(base)return`${base}/${key}`;if(this.endpoint)return`${this.endpointBaseUrl()}/${key}`;return`https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`}endpointBaseUrl(){const endpoint=this.endpoint.replace(/\/+$/,""),withScheme=/^https?:\/\//.test(endpoint)?endpoint:`https://${endpoint}`;if(this.usePathStyleEndpoint)return`${withScheme}/${this.bucket}`;const url=new URL(withScheme);url.hostname=`${this.bucket}.${url.hostname}`;return url.origin}async temporaryUrl(path,options){const key=this.prefixPath(path),expiresIn=Math.floor(normalizeExpiryToMilliseconds(options.expiresIn)/1000),MIN_EXPIRY=60,MAX_EXPIRY=604800;if(!Number.isFinite(expiresIn)||expiresIn<MIN_EXPIRY||expiresIn>MAX_EXPIRY)throw RangeError(`[storage/s3] temporaryUrl expiresIn must be between 60s and 7 days (got ${expiresIn}s)`);return await(await this.getClient()).getSignedUrl({bucket:this.bucket,key,expiresIn,operation:"getObject"})}async signedUrl(path,options){return this.temporaryUrl(path,{expiresIn:options.expiresIn})}async presignedUploadUrl(options){if(!options.contentType)throw Error("[storage/s3] presignedUploadUrl requires `contentType` - S3 signs against the exact header.");const expiresIn=Math.floor(options.expiresIn),MIN_EXPIRY=60,MAX_EXPIRY=604800;if(!Number.isFinite(expiresIn)||expiresIn<MIN_EXPIRY||expiresIn>MAX_EXPIRY)throw RangeError(`[storage/s3] presignedUploadUrl expiresIn must be between 60s and 7 days (got ${expiresIn}s)`);const safeDir=sanitizePresignedDir(options.dir),safeFilename=options.filename!==void 0?sanitizePresignedFilename(options.filename):`${crypto.randomUUID().replace(/-/g,"")}${this.extensionForContentType(options.contentType)}`,path=safeDir?`${safeDir}/${safeFilename}`:safeFilename,key=this.prefixPath(path);return{url:await(await this.getClient()).getSignedUrl({bucket:this.bucket,key,expiresIn,operation:"putObject"}),path,key,contentType:options.contentType,maxBytes:options.maxBytes}}async presignedUploadPolicy(options){const credentials=this.resolveCredentials(),scopedKey=typeof options.key==="string"?this.prefixPath(options.key):{startsWith:this.prefixPath(options.key.startsWith)};return signS3PresignedPost({bucket:this.bucket,region:this.region,credentials,key:scopedKey,contentType:options.contentType,contentLengthRange:options.contentLengthRange,acl:options.acl,expiresIn:options.expiresIn,fields:options.fields})}extensionForContentType(contentType){return extensionForContentType(contentType)}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",content=await this.readToUint8Array(path),hasher=new Bun.CryptoHasher(algorithm);hasher.update(content);return hasher.digest("hex")}async mimeType(path,_options={}){return(await this.stat(path)).mimeType||this.detectMimeType(path)}detectMimeType(path){return mimeFromExtension(path)}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}}export function createS3Storage(client,config){return new S3StorageAdapter(client,config)}
package/dist/delete.d.ts CHANGED
@@ -4,5 +4,7 @@ export declare function isDirectoryEmpty(path: string): Promise<Result<boolean,
4
4
  export declare function deleteEmptyFolder(path: string): Promise<Result<string, Error>>;
5
5
  export declare function deleteEmptyFolders(dir: string): Promise<Result<string, Error>>;
6
6
  export declare function deleteFile(path: string): Promise<Result<string, Error>>;
7
+ /*.log')` both deleted nothing.
8
+ */
7
9
  export declare function deleteGlob(path: string): Promise<Result<string, Error>>;
8
10
  export declare function del(path: string): Promise<Result<string, Error>>;
package/dist/delete.js CHANGED
@@ -1 +1 @@
1
- import{italic,log}from"@stacksjs/cli";import{err,handleError,ok}from"@stacksjs/error-handling";import{join}from"@stacksjs/path";import{isFolder}from"./folders";import{fs}from"./fs";import{glob}from"./glob";export function deleteFolder(path){return new Promise((resolve,reject)=>{try{if(isFolder(path)){fs.rmSync(path,{recursive:!0,force:!0});return resolve(ok(`Deleted ${path}`))}return resolve(ok(`Path ${path} was not a directory`))}catch(error){return reject(err(error))}})}export async function isDirectoryEmpty(path){return new Promise((resolve,reject)=>{try{if(fs.statSync(path).isDirectory()){if(fs.readdirSync(path).length===0)return resolve(ok(!0));return resolve(ok(!1))}return resolve(ok(!1))}catch(error){return reject(err(error))}})}export async function deleteEmptyFolder(path){return new Promise((resolve,reject)=>{try{if(fs.statSync(path).isDirectory()){if(fs.readdirSync(path).length===0){fs.rmSync(path,{recursive:!0,force:!0});return resolve(ok(`Deleted ${path}`))}return resolve(ok(`Path ${path} was not empty`))}return resolve(ok(`Path ${path} was not a directory`))}catch(error){return reject(err(error))}})}export async function deleteEmptyFolders(dir){try{if(!fs.existsSync(dir))return ok(`Path ${dir} does not exist`);const files=fs.readdirSync(dir);for(const file of files){const p=join(dir,file);if(isFolder(p))if(fs.readdirSync(p).length===0)fs.rmSync(p,{recursive:!0,force:!0});else await deleteEmptyFolders(p)}return ok(`Deleted empty folders located in ${dir}`)}catch(error){return err(error)}}export function deleteFile(path){return new Promise((resolve,reject)=>{try{if(fs.statSync(path).isFile()){fs.rmSync(path,{recursive:!0,force:!0});return resolve(ok(`Deleted ${path}`))}return resolve(ok(`Path ${path} was not a file`))}catch(error){return reject(err(error))}})}export async function deleteGlob(path){if(!path.includes("*"))return err(handleError(`Path ${path} does not contain a glob`));const directories=await glob([path],{onlyDirectories:!0});for(const directory of directories){const result=await deleteFolder(directory);if(result.isErr){log.error(result.error);return result}log.info(`Deleted ${italic(directory)}`)}return ok(`Deleted ${directories.length} directories`)}export async function del(path){if(fs.existsSync(path)&&fs.statSync(path).isFile())return await deleteFile(path);if(isFolder(path))return await deleteFolder(path);if(path.includes("*"))return await deleteGlob(path);return err(handleError(`Path ${path} cannot be deleted due to an unhandled condition. Please report this issue.`))}
1
+ import{italic,log}from"@stacksjs/cli";import{err,handleError,ok}from"@stacksjs/error-handling";import{join}from"@stacksjs/path";import{isFolder}from"./folders";import{fs}from"./fs";import{glob}from"./glob";export function deleteFolder(path){return new Promise((resolve)=>{try{if(isFolder(path)){fs.rmSync(path,{recursive:!0,force:!0});return resolve(ok(`Deleted ${path}`))}return resolve(ok(`Path ${path} was not a directory`))}catch(error){return resolve(err(error instanceof Error?error:Error(String(error))))}})}export async function isDirectoryEmpty(path){return new Promise((resolve)=>{try{if(fs.statSync(path).isDirectory()){if(fs.readdirSync(path).length===0)return resolve(ok(!0));return resolve(ok(!1))}return resolve(ok(!1))}catch(error){return resolve(err(error instanceof Error?error:Error(String(error))))}})}export async function deleteEmptyFolder(path){return new Promise((resolve)=>{try{if(fs.statSync(path).isDirectory()){if(fs.readdirSync(path).length===0){fs.rmSync(path,{recursive:!0,force:!0});return resolve(ok(`Deleted ${path}`))}return resolve(ok(`Path ${path} was not empty`))}return resolve(ok(`Path ${path} was not a directory`))}catch(error){return resolve(err(error instanceof Error?error:Error(String(error))))}})}export async function deleteEmptyFolders(dir){try{if(!fs.existsSync(dir))return ok(`Path ${dir} does not exist`);const files=fs.readdirSync(dir);for(const file of files){const p=join(dir,file);if(isFolder(p))if(fs.readdirSync(p).length===0)fs.rmSync(p,{recursive:!0,force:!0});else await deleteEmptyFolders(p)}return ok(`Deleted empty folders located in ${dir}`)}catch(error){return err(error)}}export function deleteFile(path){return new Promise((resolve)=>{try{if(fs.statSync(path).isFile()){fs.rmSync(path,{recursive:!0,force:!0});return resolve(ok(`Deleted ${path}`))}return resolve(ok(`Path ${path} was not a file`))}catch(error){return resolve(err(error instanceof Error?error:Error(String(error))))}})}export async function deleteGlob(path){if(!path.includes("*"))return err(handleError(`Path ${path} does not contain a glob`));const matches=(await glob([path],{onlyFiles:!1,dot:!0})).sort((a,b)=>a.split("/").length-b.split("/").length||a.localeCompare(b));let deleted=0;for(const match of matches){if(!fs.existsSync(match))continue;try{fs.rmSync(match,{recursive:!0,force:!0})}catch(error){const failure=Error(`Could not delete ${match}: ${error instanceof Error?error.message:String(error)}`,{cause:error});log.error(failure);return err(failure)}deleted++;log.info(`Deleted ${italic(match)}`)}return ok(`Deleted ${deleted} ${deleted===1?"entry":"entries"} matching ${path}`)}export async function del(path){if(fs.existsSync(path)&&fs.statSync(path).isFile())return await deleteFile(path);if(isFolder(path))return await deleteFolder(path);if(path.includes("*"))return await deleteGlob(path);return err(handleError(`Path ${path} cannot be deleted due to an unhandled condition. Please report this issue.`))}
package/dist/facade.d.ts CHANGED
@@ -12,9 +12,6 @@ export type {
12
12
  // Exports
13
13
  // =============================================================================
14
14
  export declare const Storage: StorageManager;
15
- // =============================================================================
16
- // Storage Manager
17
- // =============================================================================
18
15
  declare class StorageManager {
19
16
  init(config: Partial<FilesystemConfig>): this;
20
17
  disk(name?: DiskName): StorageAdapter;
package/dist/facade.js CHANGED
@@ -1 +1 @@
1
- import{resolve}from"node:path";import process from"node:process";import{filesystems,app as appConfig}from"@stacksjs/config";import{AzureBlobStorageAdapter}from"./adapters/azure";import{createLocalStorage}from"./adapters/local";import{S3StorageAdapter}from"./adapters/s3";import{parseDiskPath}from"./path-sanitize";import{putUploadedFile}from"./put-file";function buildConfig(){const cwd=process.cwd(),rootDir=filesystems.root||cwd,s3Config=filesystems.s3,appUrl=(appConfig?.url||"").replace(/\/$/,""),config={default:filesystems.driver||"local",disks:{local:{driver:"local",root:resolve(rootDir,"storage/app"),visibility:filesystems.defaultVisibility||"private"},public:{driver:"local",root:resolve(rootDir,"public"),url:appUrl||"/",visibility:"public"}}};if(s3Config?.bucket)config.disks.s3={driver:"s3",bucket:s3Config.bucket,region:s3Config.region||"us-east-1",prefix:s3Config.prefix,endpoint:s3Config.endpoint,url:filesystems.publicUrl?.domain,usePathStyleEndpoint:s3Config.usePathStyleEndpoint??!!s3Config.endpoint,visibility:filesystems.defaultVisibility||"private",credentials:s3Config.credentials?{key:s3Config.credentials.accessKeyId,secret:s3Config.credentials.secretAccessKey}:void 0};return config}class StorageManager{_config=null;disks=new Map;customConfig=null;get config(){if(!this._config){const builtConfig=buildConfig();this._config=this.customConfig?{default:this.customConfig.default||builtConfig.default,disks:{...builtConfig.disks,...this.customConfig.disks}}:builtConfig}return this._config}init(config){this.customConfig=config;this._config=null;this.disks.clear();return this}disk(name){const diskName=name||this.config.default;if(this.disks.has(diskName))return this.disks.get(diskName);const diskConfig=this.config.disks[diskName];if(!diskConfig){const available=Object.keys(this.config.disks).join(", ");throw Error(`Disk [${diskName}] is not configured. Available: ${available}.${diskName==="s3"?" Set `filesystems.s3.bucket` in config/filesystems.ts - the s3 disk is only built once a bucket is configured.":name===void 0?" This is the DEFAULT disk, from `filesystems.driver`; it has to name one of the disks above.":""}`)}const adapter=this.createAdapter(diskName,diskConfig);this.disks.set(diskName,adapter);return adapter}createAdapter(name,config){switch(config.driver){case"local":return this.createLocalAdapter(config);case"s3":return this.createS3Adapter(name,config);case"azure":return this.createAzureAdapter(config);default:throw Error(`Unsupported driver: ${config.driver}`)}}createLocalAdapter(config){return createLocalStorage({root:config.root,url:config.url})}createS3Adapter(_name,config){return new S3StorageAdapter(null,{bucket:config.bucket,region:config.region,prefix:config.prefix,endpoint:config.endpoint,usePathStyleEndpoint:config.usePathStyleEndpoint,url:config.url,credentials:config.credentials?{accessKeyId:config.credentials.key,secretAccessKey:config.credentials.secret}:void 0})}createAzureAdapter(config){return new AzureBlobStorageAdapter({account:config.account,accountKey:config.accountKey,sasToken:config.sasToken,container:config.container,prefix:config.prefix,url:config.url,endpoint:config.endpoint})}async put(pathOrFile,contentsOrOpts){if(typeof pathOrFile==="string")return this.disk().write(pathOrFile,contentsOrOpts);return putUploadedFile(this,pathOrFile,contentsOrOpts??{})}async stat(path){return this.disk().stat(path)}async getStream(path,options){const adapter=this.disk();if(typeof adapter.getStream!=="function")throw Error(`[storage] disk '${this.config.default}' does not support getStream - adapter is missing the optional method`);return adapter.getStream(path,options)}async putStream(path,stream,options){const adapter=this.disk();if(typeof adapter.putStream!=="function")throw Error(`[storage] disk '${this.config.default}' does not support putStream - adapter is missing the optional method`);return adapter.putStream(path,stream,options)}async copyAcross(source,dest){const src=parseDiskPath(source),dst=parseDiskPath(dest);if(src.disk===dst.disk){const adapter=this.disk(src.disk);await adapter.copyFile(src.path,dst.path);return adapter.stat(dst.path).then((entry)=>({path:dst.path,size:entry.size,contentType:entry.mimeType,lastModified:entry.lastModified}))}const contents=await this.disk(src.disk).read(src.path);return this.disk(dst.disk).write(dst.path,contents)}async moveAcross(source,dest){const src=parseDiskPath(source),result=await this.copyAcross(source,dest);await this.disk(src.disk).deleteFile(src.path);return result}async get(path){return this.disk().readToString(path)}async exists(path){return this.disk().fileExists(path)}async missing(path){return!await this.exists(path)}async delete(path){return this.disk().deleteFile(path)}async copy(from,to){return this.disk().copyFile(from,to)}async move(from,to){return this.disk().moveFile(from,to)}async url(path){return this.disk().publicUrl(path)}async signedUrl(path,options){const adapter=this.disk();if(typeof adapter.signedUrl!=="function")throw Error(`[storage] disk '${this.config.default}' does not support signedUrl`);return adapter.signedUrl(path,options)}async presignedUploadUrl(options){const adapter=this.disk();if(typeof adapter.presignedUploadUrl!=="function")throw Error(`[storage] disk '${this.config.default}' does not support presignedUploadUrl - only S3-style adapters do. Use \`Storage.put(file, opts)\` for local/proxied uploads.`);return adapter.presignedUploadUrl(options)}async presignedUploadPolicy(options){const adapter=this.disk();if(typeof adapter.presignedUploadPolicy!=="function")throw Error(`[storage] disk '${this.config.default}' does not support presignedUploadPolicy - S3-only. Use \`presignedUploadUrl\` for the PUT-form, or \`Storage.put(file, opts)\` for server-proxied uploads.`);return adapter.presignedUploadPolicy(options)}async size(path){return this.disk().fileSize(path)}async lastModified(path){return this.disk().lastModified(path)}async mimeType(path){return this.disk().mimeType(path)}async checksum(path,algorithm){return this.disk().checksum(path,{algorithm})}async makeDirectory(path){return this.disk().createDirectory(path)}async deleteDirectory(path){return this.disk().deleteDirectory(path)}files(path=""){return this.disk().list(path)}allFiles(path=""){return this.disk().list(path,{deep:!0})}configure(name,config){const currentConfig=this.config;currentConfig.disks[name]=config;this.disks.delete(name);return this}setDefaultDisk(name){if(!this.config.disks[name])throw Error(`Disk [${name}] is not configured`);this.config.default=name;return this}getDiskConfig(name){return this.config.disks[name||this.config.default]}getConfiguredDisks(){return Object.keys(this.config.disks)}getDefaultDisk(){return this.config.default}reset(){this._config=null;this.customConfig=null;this.disks.clear();return this}}export const Storage=new StorageManager;export{StorageManager};
1
+ import{posix,resolve}from"node:path";import process from"node:process";import{filesystems,app as appConfig}from"@stacksjs/config";import{AzureBlobStorageAdapter}from"./adapters/azure";import{createLocalStorage}from"./adapters/local";import{S3StorageAdapter}from"./adapters/s3";import{parseDiskPath}from"./path-sanitize";import{putUploadedFile}from"./put-file";function buildConfig(){const cwd=process.cwd(),rootDir=filesystems.root||cwd,s3Config=filesystems.s3,appUrl=(appConfig?.url||"").replace(/\/$/,""),config={default:filesystems.driver||"local",disks:{local:{driver:"local",root:resolve(rootDir,"storage/app"),visibility:filesystems.defaultVisibility||"private"},public:{driver:"local",root:resolve(rootDir,"public"),url:appUrl||"/",visibility:"public"}}};if(s3Config?.bucket)config.disks.s3={driver:"s3",bucket:s3Config.bucket,region:s3Config.region||"us-east-1",prefix:s3Config.prefix,endpoint:s3Config.endpoint,url:filesystems.publicUrl?.domain,usePathStyleEndpoint:s3Config.usePathStyleEndpoint??!!s3Config.endpoint,visibility:filesystems.defaultVisibility||"private",credentials:s3Config.credentials?{key:s3Config.credentials.accessKeyId,secret:s3Config.credentials.secretAccessKey}:void 0};return config}function sameStoragePath(a,b){return posix.normalize(a.replace(/\\/g,"/"))===posix.normalize(b.replace(/\\/g,"/"))}class StorageManager{_config=null;disks=new Map;customConfig=null;get config(){if(!this._config){const builtConfig=buildConfig();this._config=this.customConfig?{default:this.customConfig.default||builtConfig.default,disks:{...builtConfig.disks,...this.customConfig.disks}}:builtConfig}return this._config}init(config){this.customConfig=config;this._config=null;this.disks.clear();return this}disk(name){const diskName=name||this.config.default;if(this.disks.has(diskName))return this.disks.get(diskName);const diskConfig=this.config.disks[diskName];if(!diskConfig){const available=Object.keys(this.config.disks).join(", ");throw Error(`Disk [${diskName}] is not configured. Available: ${available}.${diskName==="s3"?" Set `filesystems.s3.bucket` in config/filesystems.ts - the s3 disk is only built once a bucket is configured.":name===void 0?" This is the DEFAULT disk, from `filesystems.driver`; it has to name one of the disks above.":""}`)}const adapter=this.createAdapter(diskName,diskConfig);this.disks.set(diskName,adapter);return adapter}createAdapter(name,config){switch(config.driver){case"local":return this.createLocalAdapter(name,config);case"s3":return this.createS3Adapter(name,config);case"azure":return this.createAzureAdapter(config);default:throw Error(`Unsupported driver: ${config.driver}`)}}createLocalAdapter(name,config){return createLocalStorage({root:config.root,url:config.url,disk:name})}createS3Adapter(_name,config){return new S3StorageAdapter(null,{bucket:config.bucket,region:config.region,prefix:config.prefix,endpoint:config.endpoint,usePathStyleEndpoint:config.usePathStyleEndpoint,url:config.url,credentials:config.credentials?{accessKeyId:config.credentials.key,secretAccessKey:config.credentials.secret}:void 0})}createAzureAdapter(config){return new AzureBlobStorageAdapter({account:config.account,accountKey:config.accountKey,sasToken:config.sasToken,container:config.container,prefix:config.prefix,url:config.url,endpoint:config.endpoint})}async put(pathOrFile,contentsOrOpts){if(typeof pathOrFile==="string")return this.disk().write(pathOrFile,contentsOrOpts);return putUploadedFile(this,pathOrFile,contentsOrOpts??{})}async stat(path){return this.disk().stat(path)}async getStream(path,options){const adapter=this.disk();if(typeof adapter.getStream!=="function")throw Error(`[storage] disk '${this.config.default}' does not support getStream - adapter is missing the optional method`);return adapter.getStream(path,options)}async putStream(path,stream,options){const adapter=this.disk();if(typeof adapter.putStream!=="function")throw Error(`[storage] disk '${this.config.default}' does not support putStream - adapter is missing the optional method`);return adapter.putStream(path,stream,options)}async copyAcross(source,dest){const src=parseDiskPath(source),dst=parseDiskPath(dest);if(src.disk===dst.disk){const adapter=this.disk(src.disk);if(!sameStoragePath(src.path,dst.path))await adapter.copyFile(src.path,dst.path);return adapter.stat(dst.path).then((entry)=>({path:dst.path,size:entry.size,contentType:entry.mimeType,lastModified:entry.lastModified}))}const contents=await this.disk(src.disk).read(src.path);return this.disk(dst.disk).write(dst.path,contents)}async moveAcross(source,dest){const src=parseDiskPath(source),dst=parseDiskPath(dest);if(src.disk===dst.disk){const adapter=this.disk(src.disk);if(!sameStoragePath(src.path,dst.path))await adapter.moveFile(src.path,dst.path);const entry=await adapter.stat(dst.path);return{path:dst.path,size:entry.size,contentType:entry.mimeType,lastModified:entry.lastModified}}const result=await this.copyAcross(source,dest);await this.disk(src.disk).deleteFile(src.path);return result}async get(path){return this.disk().readToString(path)}async exists(path){return this.disk().fileExists(path)}async missing(path){return!await this.exists(path)}async delete(path){return this.disk().deleteFile(path)}async copy(from,to){return this.disk().copyFile(from,to)}async move(from,to){return this.disk().moveFile(from,to)}async url(path){return this.disk().publicUrl(path)}async signedUrl(path,options){const adapter=this.disk();if(typeof adapter.signedUrl!=="function")throw Error(`[storage] disk '${this.config.default}' does not support signedUrl`);return adapter.signedUrl(path,options)}async presignedUploadUrl(options){const adapter=this.disk();if(typeof adapter.presignedUploadUrl!=="function")throw Error(`[storage] disk '${this.config.default}' does not support presignedUploadUrl - only S3-style adapters do. Use \`Storage.put(file, opts)\` for local/proxied uploads.`);return adapter.presignedUploadUrl(options)}async presignedUploadPolicy(options){const adapter=this.disk();if(typeof adapter.presignedUploadPolicy!=="function")throw Error(`[storage] disk '${this.config.default}' does not support presignedUploadPolicy - S3-only. Use \`presignedUploadUrl\` for the PUT-form, or \`Storage.put(file, opts)\` for server-proxied uploads.`);return adapter.presignedUploadPolicy(options)}async size(path){return this.disk().fileSize(path)}async lastModified(path){return this.disk().lastModified(path)}async mimeType(path){return this.disk().mimeType(path)}async checksum(path,algorithm){return this.disk().checksum(path,{algorithm})}async makeDirectory(path){return this.disk().createDirectory(path)}async deleteDirectory(path){return this.disk().deleteDirectory(path)}files(path=""){return this.disk().list(path)}allFiles(path=""){return this.disk().list(path,{deep:!0})}configure(name,config){const currentConfig=this.config;currentConfig.disks[name]=config;this.disks.delete(name);return this}setDefaultDisk(name){if(!this.config.disks[name])throw Error(`Disk [${name}] is not configured`);this.config.default=name;return this}getDiskConfig(name){return this.config.disks[name||this.config.default]}getConfiguredDisks(){return Object.keys(this.config.disks)}getDefaultDisk(){return this.config.default}reset(){this._config=null;this.customConfig=null;this.disks.clear();return this}}export const Storage=new StorageManager;export{StorageManager};
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Write `fullPath` through a temporary file beside it, renamed into place
3
+ * once the write has finished.
4
+ *
5
+ * Writing in place truncated the existing file before the first byte of the
6
+ * new one arrived. A stream that failed or was aborted left the old contents
7
+ * gone and the new ones partial, and `putStream` then deleted the partial
8
+ * file to tidy up - so a failed re-upload of an avatar removed the avatar. A
9
+ * reader in the meantime saw a half-written file. Now the old file stays
10
+ * whole until the new one is complete, readers see one or the other, and a
11
+ * failure removes only the temporary file. The replaced file's mode carries
12
+ * over, so a private file stays private.
13
+ */
14
+ export declare function writeAtomically(fullPath: string, write: (temporary: string) => Promise<void>): Promise<void>;
15
+ /**
16
+ * Percent-encode each segment of a storage path, keeping its slashes: a file
17
+ * called `my photo #1.jpg` becomes `my%20photo%20%231.jpg`. Unencoded, the
18
+ * `#` began a fragment and the browser asked for `my photo `.
19
+ */
20
+ export declare function encodeStoragePath(path: string): string;
21
+ /**
22
+ * The public URL of `path` on a disk served from `diskUrl`.
23
+ *
24
+ * The disk's own `url` is used whenever it is configured, including `'/'`
25
+ * (files served from the site root). That one was dropped: the trailing
26
+ * slash was trimmed to `''`, which read as "not configured", and the URL
27
+ * fell through to `APP_URL` - correct by accident only when the app's root
28
+ * was where the files were served.
29
+ */
30
+ export declare function publicUrlFor(path: string, options: { domain?: string, diskUrl?: string }): string;
31
+ /**
32
+ * The origin a signed URL is served from: `baseUrl`, else `APP_URL`, else
33
+ * localhost.
34
+ */
35
+ export declare function signedUrlBase(baseUrl?: string): string;
@@ -0,0 +1 @@
1
+ import{randomBytes}from"node:crypto";import{chmod,mkdir,rename,rm,stat}from"node:fs/promises";import{basename,dirname,join}from"node:path";import process from"node:process";export async function writeAtomically(fullPath,write){await mkdir(dirname(fullPath),{recursive:!0});const temporary=join(dirname(fullPath),`.${basename(fullPath)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);try{await write(temporary);const previous=await stat(fullPath).catch(()=>null);if(previous)await chmod(temporary,previous.mode&4095);await rename(temporary,fullPath)}catch(error){await rm(temporary,{force:!0}).catch(()=>{});throw error}}export function encodeStoragePath(path){return path.split("/").map((segment)=>encodeURIComponent(segment)).join("/")}export function publicUrlFor(path,options){return`${withScheme(options.domain??options.diskUrl??process.env.APP_URL??"http://localhost").replace(/\/+$/,"")}/${encodeStoragePath(path.replace(/^\/+/,""))}`}export function signedUrlBase(baseUrl){return withScheme(baseUrl||process.env.APP_URL||"http://localhost").replace(/\/+$/,"")}function withScheme(base){if(base===""||base.startsWith("/")||/^[a-z][a-z0-9+.-]*:\/\//i.test(base))return base;return`https://${base}`}
package/dist/glob.js CHANGED
@@ -1 +1 @@
1
- import{Glob as BunGlob}from"bun";function isEnoent(err){return!!err&&typeof err==="object"&&err.code==="ENOENT"}export function globSync(patterns,options){const patternArray=typeof patterns==="string"?[patterns]:patterns,results=[];for(const pattern of patternArray)try{const matches=new BunGlob(pattern).scanSync({cwd:options?.cwd,absolute:options?.absolute,dot:options?.dot,onlyFiles:options?.onlyFiles});for(const match of matches)results.push(match)}catch(err){if(!isEnoent(err))throw err}return results}export async function glob(patterns,options){const patternArray=typeof patterns==="string"?[patterns]:patterns,results=[];for(const pattern of patternArray)try{const matches=new BunGlob(pattern).scan({cwd:options?.cwd,absolute:options?.absolute,dot:options?.dot,onlyFiles:options?.onlyFiles});for await(const match of matches)results.push(match)}catch(err){if(!isEnoent(err))throw err}return results}
1
+ import{Glob as BunGlob}from"bun";import{statSync}from"node:fs";import{isAbsolute,join,relative,sep}from"node:path";import process from"node:process";function isEnoent(err){return!!err&&typeof err==="object"&&err.code==="ENOENT"}const globCharacters=/[*?[\]{}!]/;function baseOf(pattern){const segments=pattern.split("/"),literal=[];for(const segment of segments.slice(0,-1)){if(globCharacters.test(segment))break;literal.push(segment)}return literal.join("/")||(pattern.startsWith("/")?"/":"")}function isDirectory(path){try{return statSync(path).isDirectory()}catch{return!1}}function plan(pattern,options={}){const cwd=options.cwd??process.cwd();let effective=pattern;if(options.expandDirectories&&!globCharacters.test(pattern)&&isDirectory(isAbsolute(pattern)?pattern:join(cwd,pattern)))effective=`${pattern.replace(/\/+$/,"")}/**`;const onlyDirectories=options.onlyDirectories===!0,onlyFiles=!onlyDirectories&&options.onlyFiles!==!1,ignore=(options.ignore??[]).map((ignored)=>new BunGlob(ignored)),base=baseOf(effective),baseDirectory=isAbsolute(base)?base:join(cwd,base),keep=(match)=>{const onDisk=isAbsolute(match)?match:join(cwd,match),fromCwd=relative(cwd,onDisk).split(sep).join("/");if(ignore.some((ignored)=>ignored.match(match)||ignored.match(fromCwd)))return!1;if(options.deep!==void 0){if(relative(baseDirectory,onDisk).split(sep).filter(Boolean).length-1>options.deep)return!1}if(onlyDirectories&&!isDirectory(onDisk))return!1;return!0};return{pattern:effective,scan:{cwd:options.cwd,absolute:options.absolute,dot:options.dot,onlyFiles},keep}}export function globSync(patterns,options){const patternArray=typeof patterns==="string"?[patterns]:patterns,results=new Set;for(const pattern of patternArray){const{pattern:effective,scan,keep}=plan(pattern,options);try{for(const match of new BunGlob(effective).scanSync(scan))if(keep(match))results.add(match)}catch(err){if(!isEnoent(err))throw err}}return[...results]}export async function glob(patterns,options){const patternArray=typeof patterns==="string"?[patterns]:patterns,results=new Set;for(const pattern of patternArray){const{pattern:effective,scan,keep}=plan(pattern,options);try{for await(const match of new BunGlob(effective).scan(scan))if(keep(match))results.add(match)}catch(err){if(!isEnoent(err))throw err}}return[...results]}
package/dist/hash.d.ts CHANGED
@@ -1,5 +1,26 @@
1
1
  import type { Hash } from 'node:crypto';
2
+ /**
3
+ * Feed `path` - a file, a directory tree, a symlink, or nothing - into `hash`.
4
+ *
5
+ * What is hashed is the tree's SHAPE as well as its bytes, as length-prefixed
6
+ * records: each entry's kind and its name relative to `path`, then a file's
7
+ * length and contents. It used to be the contents alone, concatenated in
8
+ * whatever order `readdir` returned:
9
+ *
10
+ * - renaming or moving a file left the hash unchanged, so
11
+ * `websiteSourceHash()` saw no change in a site whose view had moved;
12
+ * - `a.txt` = "ab" beside `b.txt` = "c" hashed as `a.txt` = "a" beside
13
+ * `b.txt` = "bc";
14
+ * - `readdir` order is the filesystem's - sorted on APFS, hash order on
15
+ * ext4 - so one tree hashed differently on a laptop and in CI.
16
+ *
17
+ * Names are relative, so the same tree hashes the same wherever it sits.
18
+ * A symlink is hashed as its target rather than followed, which also keeps a
19
+ * link back up the tree from recursing forever. A path that does not exist
20
+ * hashes as missing, so creating it changes the hash.
21
+ */
2
22
  export declare function hashFileOrDirectory(path: string, hash: Hash): void;
3
23
  export declare function hashDirectory(directory: string): string;
4
24
  export declare function hashPath(path: string): string;
25
+ /** Hash several paths together. Their order counts, so pass them in a fixed one. */
5
26
  export declare function hashPaths(paths: string | string[]): string;
package/dist/hash.js CHANGED
@@ -1 +1 @@
1
- import{createHash}from"node:crypto";import{path as p}from"@stacksjs/path";import{fs}from"./fs";export function hashFileOrDirectory(path,hash){if(!fs.existsSync(path)){console.error(`Path does not exist: ${path}`);return}if(fs.statSync(path).isDirectory()){const files=fs.readdirSync(path);for(const file of files){const filePath=p.join(path,file);hashFileOrDirectory(filePath,hash)}}else hash.update(fs.readFileSync(path))}export function hashDirectory(directory){const hash=createHash("sha256");hashFileOrDirectory(directory,hash);return hash.digest("hex")}export function hashPath(path){const hash=createHash("sha256");hashFileOrDirectory(path,hash);return hash.digest("hex")}export function hashPaths(paths){const hash=createHash("sha256"),pathsArray=Array.isArray(paths)?paths:[paths];for(const path of pathsArray)hashFileOrDirectory(path,hash);return hash.digest("hex")}
1
+ import{Buffer}from"node:buffer";import{createHash}from"node:crypto";import{path as p}from"@stacksjs/path";import{fs}from"./fs";export function hashFileOrDirectory(path,hash){hashEntry(path,".",hash)}function record(hash,...fields){for(const field of fields){const text=String(field);hash.update(`${Buffer.byteLength(text)}:`);hash.update(text)}}function hashEntry(path,name,hash){let stat;try{stat=fs.lstatSync(path)}catch(error){if(error.code!=="ENOENT")throw error;record(hash,"missing",name);return}if(stat.isSymbolicLink()){record(hash,"link",name,fs.readlinkSync(path));return}if(stat.isDirectory()){record(hash,"directory",name);const entries=fs.readdirSync(path).sort((a,b)=>a<b?-1:a>b?1:0);for(const entry of entries)hashEntry(p.join(path,entry),name==="."?entry:`${name}/${entry}`,hash);return}const contents=fs.readFileSync(path);record(hash,"file",name,contents.length);hash.update(contents)}export function hashDirectory(directory){return hashPath(directory)}export function hashPath(path){const hash=createHash("sha256");hashFileOrDirectory(path,hash);return hash.digest("hex")}export function hashPaths(paths){const hash=createHash("sha256");(Array.isArray(paths)?paths:[paths]).forEach((path,index)=>{record(hash,"root",index);hashFileOrDirectory(path,hash)});return hash.digest("hex")}
package/dist/move.d.ts CHANGED
@@ -1,5 +1,17 @@
1
1
  import type { Result } from '@stacksjs/error-handling';
2
2
  export declare function move(src: string | string[], dest: string, options?: MoveOptions): Promise<Result<{ message: string }, Error>>;
3
+ /**
4
+ * Rename `from` to `to`, creating `to`'s directory.
5
+ *
6
+ * Every failure RESOLVES to an `Err`. They used to reject with one, so a
7
+ * caller doing `const result = await rename(...); if (result.isErr)` - the
8
+ * shape the return type promises, and the one `move()` itself uses - got an
9
+ * exception instead, whose thrown value was a Result rather than an Error.
10
+ *
11
+ * Renaming a file onto itself is a no-op, or a change of case on a
12
+ * case-insensitive disk. With `overwrite` it used to delete the destination
13
+ * first, which was the source: the file was gone before the rename ran.
14
+ */
3
15
  export declare function rename(from: string, to: string, options?: MoveOptions): Promise<Result<{ message: string }, Error>>;
4
16
  declare interface MoveOptions {
5
17
  overwrite?: boolean
package/dist/move.js CHANGED
@@ -1,3 +1,3 @@
1
- import{err,handleError,ok}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{path}from"@stacksjs/path";import{fs}from"./fs";export async function move(src,dest,options){try{if(Array.isArray(src)){const errors=[],operations=src.map(async(file)=>{const from=file,to=path.resolve(dest,path.basename(file)),result=await rename(from,to,options);if(result.isErr){log.error(result.error);errors.push(result.error)}});await Promise.all(operations);if(errors.length>0)return err(handleError(errors[0]));return ok({message:"Files moved successfully"})}const result=await rename(src,dest,options);if(result.isErr){log.error(result.error);return err(handleError(result.error))}return ok({message:"File moved successfully"})}catch(error){return err(handleError(error))}}export async function rename(from,to,options){return new Promise((resolve,reject)=>{try{const dir=path.dirname(to);if(!fs.existsSync(dir))fs.mkdirSync(dir,{recursive:!0});if(!fs.existsSync(from))return reject(err(Error(`File or directory does not exist: ${from}`)));if(fs.existsSync(to)){if(!options?.overwrite)return reject(err(Error(`File or directory already exists: ${to}`)));fs.rmSync(to,{recursive:!0,force:!0})}fs.renameSync(from,to);return resolve(ok({message:"File moved successfully"}))}catch(error){if(error.code==="ENOENT")log.error(`File or directory does not exist
1
+ import{err,handleError,ok}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{path}from"@stacksjs/path";import{fs}from"./fs";export async function move(src,dest,options){try{if(Array.isArray(src)){const errors=[],operations=src.map(async(file)=>{const from=file,to=path.resolve(dest,path.basename(file)),result=await rename(from,to,options);if(result.isErr){log.error(result.error);errors.push(result.error)}});await Promise.all(operations);if(errors.length>0)return err(handleError(errors[0]));return ok({message:"Files moved successfully"})}const result=await rename(src,dest,options);if(result.isErr){log.error(result.error);return err(handleError(result.error))}return ok({message:"File moved successfully"})}catch(error){return err(handleError(error))}}export async function rename(from,to,options){try{if(!fs.existsSync(from))return err(Error(`File or directory does not exist: ${from}`));const dir=path.dirname(to);if(!fs.existsSync(dir))fs.mkdirSync(dir,{recursive:!0});if(fs.existsSync(to)&&!isSameEntry(from,to)){if(!options?.overwrite)return err(Error(`File or directory already exists: ${to}`));fs.rmSync(to,{recursive:!0,force:!0})}fs.renameSync(from,to);return ok({message:"File moved successfully"})}catch(error){if(error?.code==="ENOENT")log.error(`File or directory does not exist
2
2
 
3
- `,error);else log.error(error);return reject(err(Error(error)))}})}
3
+ `,error);else log.error(error);return err(error instanceof Error?error:Error(String(error)))}}function isSameEntry(a,b){if(path.resolve(a)===path.resolve(b))return!0;try{const left=fs.statSync(a),right=fs.statSync(b);return left.dev===right.dev&&left.ino===right.ino}catch{return!1}}
@@ -58,6 +58,7 @@ declare interface SignedTokenClaims {
58
58
  iat: number
59
59
  exp: number
60
60
  path: string
61
+ disk?: string
61
62
  }
62
63
  /**
63
64
  * Result of verifying a signed token.
@@ -1 +1 @@
1
- import{Buffer}from"node:buffer";import{createHmac,timingSafeEqual}from"node:crypto";import process from"node:process";const ALG="HS256";function getAppKey(){const k=process.env.APP_KEY;if(!k||k.length<16){if(process.env.APP_ENV==="production"||process.env.NODE_ENV==="production")throw Error("[storage/signed-url] APP_KEY is missing or too short (need \u226516 chars). Cannot sign URL.")}return k||"stacks-default-key-dev-only-do-not-use-prod"}function base64UrlEncode(buf){return buf.toString("base64url")}function base64UrlDecode(str){return Buffer.from(str,"base64url")}function normalizeExpiry(expiresIn){if(expiresIn instanceof Date)return Math.floor(expiresIn.getTime()/1000);return Math.floor(Date.now()/1000)+Math.floor(expiresIn)}export function createSignedStorageToken(path,options){const exp=normalizeExpiry(options.expiresIn),iat=Math.floor(Date.now()/1000),header={alg:ALG,typ:"JWT"},payload={iss:options.issuer||"stacks",iat,exp,path},headerPart=base64UrlEncode(Buffer.from(JSON.stringify(header))),payloadPart=base64UrlEncode(Buffer.from(JSON.stringify(payload))),signingInput=`${headerPart}.${payloadPart}`,sig=base64UrlEncode(createHmac("sha256",getAppKey()).update(signingInput).digest());return`${signingInput}.${sig}`}const REVOCATION_LIMIT=1e5,revokedSignatures=new Set;function rememberRevoked(sigPart){if(revokedSignatures.has(sigPart))return;if(revokedSignatures.size>=REVOCATION_LIMIT){const oldest=revokedSignatures.values().next().value;if(oldest!==void 0)revokedSignatures.delete(oldest)}revokedSignatures.add(sigPart)}export function revokeSignedStorageToken(token){if(typeof token!=="string"||token.length===0)return;const parts=token.split("."),sig=parts.length===3?parts[2]:token;if(sig)rememberRevoked(sig)}export function isSignedStorageTokenRevoked(sigPart){return revokedSignatures.has(sigPart)}export function clearRevokedSignedStorageTokens(){revokedSignatures.clear()}export function verifySignedStorageToken(token,requestedPath){if(typeof token!=="string")return{valid:!1,reason:"malformed"};const parts=token.split(".");if(parts.length!==3)return{valid:!1,reason:"malformed"};const headerPart=parts[0],payloadPart=parts[1],sigPart=parts[2],signingInput=`${headerPart}.${payloadPart}`,expectedSig=createHmac("sha256",getAppKey()).update(signingInput).digest();let providedSig;try{providedSig=base64UrlDecode(sigPart)}catch{return{valid:!1,reason:"malformed"}}if(providedSig.length!==expectedSig.length||!timingSafeEqual(providedSig,expectedSig))return{valid:!1,reason:"bad_signature"};if(revokedSignatures.has(sigPart))return{valid:!1,reason:"revoked"};let claims;try{claims=JSON.parse(base64UrlDecode(payloadPart).toString("utf8"))}catch{return{valid:!1,reason:"malformed"}}const now=Math.floor(Date.now()/1000);if(typeof claims.exp!=="number"||now>=claims.exp)return{valid:!1,reason:"expired"};if(claims.path!==requestedPath)return{valid:!1,reason:"path_mismatch"};return{valid:!0,claims}}
1
+ import{Buffer}from"node:buffer";import{createHmac,timingSafeEqual}from"node:crypto";import process from"node:process";const ALG="HS256";function getAppKey(){const k=process.env.APP_KEY;if(!k||k.length<16){if(process.env.APP_ENV==="production"||process.env.NODE_ENV==="production")throw Error("[storage/signed-url] APP_KEY is missing or too short (need \u226516 chars). Cannot sign URL.")}return k||"stacks-default-key-dev-only-do-not-use-prod"}function base64UrlEncode(buf){return buf.toString("base64url")}function base64UrlDecode(str){return Buffer.from(str,"base64url")}function normalizeExpiry(expiresIn){if(expiresIn instanceof Date)return Math.floor(expiresIn.getTime()/1000);return Math.floor(Date.now()/1000)+Math.floor(expiresIn)}export function createSignedStorageToken(path,options){const exp=normalizeExpiry(options.expiresIn),iat=Math.floor(Date.now()/1000),header={alg:ALG,typ:"JWT"},payload={iss:options.issuer||"stacks",iat,exp,path,...options.disk?{disk:options.disk}:{}},headerPart=base64UrlEncode(Buffer.from(JSON.stringify(header))),payloadPart=base64UrlEncode(Buffer.from(JSON.stringify(payload))),signingInput=`${headerPart}.${payloadPart}`,sig=base64UrlEncode(createHmac("sha256",getAppKey()).update(signingInput).digest());return`${signingInput}.${sig}`}const REVOCATION_LIMIT=1e5,revokedSignatures=new Set;function rememberRevoked(sigPart){if(revokedSignatures.has(sigPart))return;if(revokedSignatures.size>=REVOCATION_LIMIT){const oldest=revokedSignatures.values().next().value;if(oldest!==void 0)revokedSignatures.delete(oldest)}revokedSignatures.add(sigPart)}export function revokeSignedStorageToken(token){if(typeof token!=="string"||token.length===0)return;const parts=token.split("."),sig=parts.length===3?parts[2]:token;if(sig)rememberRevoked(sig)}export function isSignedStorageTokenRevoked(sigPart){return revokedSignatures.has(sigPart)}export function clearRevokedSignedStorageTokens(){revokedSignatures.clear()}export function verifySignedStorageToken(token,requestedPath){if(typeof token!=="string")return{valid:!1,reason:"malformed"};const parts=token.split(".");if(parts.length!==3)return{valid:!1,reason:"malformed"};const headerPart=parts[0],payloadPart=parts[1],sigPart=parts[2],signingInput=`${headerPart}.${payloadPart}`,expectedSig=createHmac("sha256",getAppKey()).update(signingInput).digest();let providedSig;try{providedSig=base64UrlDecode(sigPart)}catch{return{valid:!1,reason:"malformed"}}if(providedSig.length!==expectedSig.length||!timingSafeEqual(providedSig,expectedSig))return{valid:!1,reason:"bad_signature"};if(revokedSignatures.has(sigPart))return{valid:!1,reason:"revoked"};let claims;try{claims=JSON.parse(base64UrlDecode(payloadPart).toString("utf8"))}catch{return{valid:!1,reason:"malformed"}}const now=Math.floor(Date.now()/1000);if(typeof claims.exp!=="number"||now>=claims.exp)return{valid:!1,reason:"expired"};if(claims.path!==requestedPath)return{valid:!1,reason:"path_mismatch"};return{valid:!0,claims}}
@@ -3,8 +3,9 @@
3
3
  * - Strong ETag (sha256, first 16 hex chars) computed once per
4
4
  * `(path, mtime)` and cached in-process.
5
5
  * - `Last-Modified` from the file's mtime.
6
- * - 304 Not Modified when `If-None-Match` matches OR
7
- * `If-Modified-Since` is at-or-after mtime (and ETag didn't mismatch).
6
+ * - 304 Not Modified on GET/HEAD when `If-None-Match` names the ETag
7
+ * (or is `*`), or, only when no `If-None-Match` was sent, when
8
+ * `If-Modified-Since` is at or after the mtime's second.
8
9
  * - `Cache-Control: public, max-age=31536000, immutable` for paths
9
10
  * that look fingerprinted (e.g. `/_assets/foo.abc12345.js`).
10
11
  * - `Cache-Control: public, max-age=300, must-revalidate` for
@@ -1 +1 @@
1
- import{createHash}from"node:crypto";import{extname}from"node:path";const etagCache=new Map,ETAG_CACHE_MAX=5000,FINGERPRINTED=/\.[a-f0-9]{8,}\./;export async function serveFile(req,filePath,options={}){const file=Bun.file(filePath);if(!await file.exists())return new Response("Not Found",{status:404});const stat=await file.stat(),mtimeMs=Math.floor(typeof stat.mtimeMs==="number"?stat.mtimeMs:stat.mtime?.getTime()??Date.now()),lastModified=new Date(mtimeMs).toUTCString(),contentType=options.contentType||file.type||guessMime(filePath);let etag;if(options.etag!==!1)etag=await computeEtag(filePath,mtimeMs,file);const ifNoneMatch=req.headers.get("if-none-match");if(etag&&ifNoneMatch&&stripWeak(ifNoneMatch)===etag)return new Response(null,{status:304,headers:buildHeaders({etag,lastModified,contentType,filePath,options,omitContentType:!0})});const ifModifiedSince=req.headers.get("if-modified-since");if(ifModifiedSince){const sinceMs=Date.parse(ifModifiedSince);if(!Number.isNaN(sinceMs)&&mtimeMs<=sinceMs)return new Response(null,{status:304,headers:buildHeaders({etag,lastModified,contentType,filePath,options,omitContentType:!0})})}return new Response(file,{status:200,headers:buildHeaders({etag,lastModified,contentType,filePath,options})})}function buildHeaders(input){const h=new Headers;if(!input.omitContentType)h.set("Content-Type",input.contentType);h.set("Last-Modified",input.lastModified);if(input.etag)h.set("ETag",`"${input.etag}"`);h.set("Cache-Control",input.options.cacheControl||pickCacheControl(input.filePath,input.options));h.set("Vary","Accept-Encoding");return h}function pickCacheControl(filePath,options){if(FINGERPRINTED.test(filePath))return"public, max-age=31536000, immutable";return`public, max-age=${options.defaultMaxAge??300}, must-revalidate`}async function computeEtag(filePath,mtimeMs,file){const cached=etagCache.get(filePath);if(cached&&cached.mtimeMs===mtimeMs)return cached.etag;const buf=await file.arrayBuffer(),etag=createHash("sha256").update(new Uint8Array(buf)).digest("hex").slice(0,16);if(etagCache.size>=ETAG_CACHE_MAX){const firstKey=etagCache.keys().next().value;if(firstKey!==void 0)etagCache.delete(firstKey)}etagCache.set(filePath,{mtimeMs,etag});return etag}function stripWeak(v){let s=v.trim();if(s.startsWith("W/"))s=s.slice(2);if(s.startsWith('"')&&s.endsWith('"'))s=s.slice(1,-1);return s}function guessMime(filePath){switch(extname(filePath).toLowerCase()){case".js":case".mjs":case".cjs":return"application/javascript; charset=utf-8";case".css":return"text/css; charset=utf-8";case".html":case".htm":return"text/html; charset=utf-8";case".json":return"application/json; charset=utf-8";case".svg":return"image/svg+xml";case".webp":return"image/webp";case".png":return"image/png";case".jpg":case".jpeg":return"image/jpeg";case".gif":return"image/gif";case".ico":return"image/x-icon";case".woff":return"font/woff";case".woff2":return"font/woff2";case".ttf":return"font/ttf";case".txt":return"text/plain; charset=utf-8";case".wasm":return"application/wasm";case".map":return"application/json; charset=utf-8";default:return"application/octet-stream"}}
1
+ import{createHash}from"node:crypto";import{extname}from"node:path";const etagCache=new Map,ETAG_CACHE_MAX=5000,FINGERPRINTED=/\.[a-f0-9]{8,}\./;export async function serveFile(req,filePath,options={}){const file=Bun.file(filePath);if(!await file.exists())return new Response("Not Found",{status:404});const stat=await file.stat(),mtimeMs=Math.floor(typeof stat.mtimeMs==="number"?stat.mtimeMs:stat.mtime?.getTime()??Date.now()),lastModified=new Date(mtimeMs).toUTCString(),contentType=options.contentType||file.type||guessMime(filePath);let etag;if(options.etag!==!1)etag=await computeEtag(filePath,mtimeMs,file);if(req.method==="GET"||req.method==="HEAD"){const headers=()=>buildHeaders({etag,lastModified,contentType,filePath,options,omitContentType:!0}),ifNoneMatch=req.headers.get("if-none-match");if(ifNoneMatch!==null){if(noneMatchHits(ifNoneMatch,etag))return new Response(null,{status:304,headers:headers()})}else{const ifModifiedSince=req.headers.get("if-modified-since"),sinceMs=ifModifiedSince?Date.parse(ifModifiedSince):Number.NaN;if(!Number.isNaN(sinceMs)&&Math.floor(mtimeMs/1000)*1000<=sinceMs)return new Response(null,{status:304,headers:headers()})}}return new Response(file,{status:200,headers:buildHeaders({etag,lastModified,contentType,filePath,options})})}function buildHeaders(input){const h=new Headers;if(!input.omitContentType)h.set("Content-Type",input.contentType);h.set("Last-Modified",input.lastModified);if(input.etag)h.set("ETag",`"${input.etag}"`);h.set("Cache-Control",input.options.cacheControl||pickCacheControl(input.filePath,input.options));h.set("Vary","Accept-Encoding");return h}function pickCacheControl(filePath,options){if(FINGERPRINTED.test(filePath))return"public, max-age=31536000, immutable";return`public, max-age=${options.defaultMaxAge??300}, must-revalidate`}async function computeEtag(filePath,mtimeMs,file){const cached=etagCache.get(filePath);if(cached&&cached.mtimeMs===mtimeMs)return cached.etag;const buf=await file.arrayBuffer(),etag=createHash("sha256").update(new Uint8Array(buf)).digest("hex").slice(0,16);if(etagCache.size>=ETAG_CACHE_MAX){const firstKey=etagCache.keys().next().value;if(firstKey!==void 0)etagCache.delete(firstKey)}etagCache.set(filePath,{mtimeMs,etag});return etag}function noneMatchHits(header,etag){const value=header.trim();if(value==="*")return!0;if(!etag)return!1;return value.split(",").some((entry)=>{let tag=entry.trim();if(tag.startsWith("W/"))tag=tag.slice(2);if(tag.startsWith('"')&&tag.endsWith('"'))tag=tag.slice(1,-1);return tag===etag})}function guessMime(filePath){switch(extname(filePath).toLowerCase()){case".js":case".mjs":case".cjs":return"application/javascript; charset=utf-8";case".css":return"text/css; charset=utf-8";case".html":case".htm":return"text/html; charset=utf-8";case".json":return"application/json; charset=utf-8";case".svg":return"image/svg+xml";case".webp":return"image/webp";case".png":return"image/png";case".jpg":case".jpeg":return"image/jpeg";case".gif":return"image/gif";case".ico":return"image/x-icon";case".woff":return"font/woff";case".woff2":return"font/woff2";case".ttf":return"font/ttf";case".txt":return"text/plain; charset=utf-8";case".wasm":return"application/wasm";case".map":return"application/json; charset=utf-8";default:return"application/octet-stream"}}
package/dist/types.d.ts CHANGED
@@ -112,6 +112,7 @@ export declare interface SignedUrlOptions {
112
112
  expiresIn: number | Date
113
113
  issuer?: string
114
114
  baseUrl?: string
115
+ disk?: string
115
116
  }
116
117
  /**
117
118
  * Options for `presignedUploadPolicy()` — the POST-form upload
@@ -175,6 +176,7 @@ export declare interface MimeTypeOptions {
175
176
  */
176
177
  export declare interface StorageAdapterConfig {
177
178
  root?: string
179
+ disk?: string
178
180
  url?: string
179
181
  bucket?: string
180
182
  region?: string
package/dist/zip.d.ts CHANGED
@@ -17,6 +17,14 @@ import type { Result } from '@stacksjs/error-handling';
17
17
  * in it also works now, which the string form could never have managed.
18
18
  */
19
19
  export declare function zip(from: string | string[], to?: string, options?: ZipOptions): Promise<Result<Subprocess, CommandError>>;
20
+ /**
21
+ * Extract each archive in `paths`, in order, stopping at the first failure.
22
+ *
23
+ * One `unzip` run per archive: `unzip -o a.zip b.zip` does not extract two
24
+ * archives, it extracts the member named `b.zip` from `a.zip`, so with more
25
+ * than one path nothing was extracted from any archive but the first, and
26
+ * from that one only a member that happened to share the second's name.
27
+ */
20
28
  export declare function unzip(paths: string | string[], options?: ZipOptions): Promise<Result<Subprocess, CommandError>>;
21
29
  export declare function archive(paths: string | string[], to?: string, options?: ZipOptions): Promise<Result<Subprocess, CommandError>>;
22
30
  export declare function unarchive(paths: string | string[], options?: ZipOptions): Promise<Result<Subprocess, CommandError>>;
package/dist/zip.js CHANGED
@@ -1 +1 @@
1
- import{runCommand}from"@stacksjs/cli";export async function zip(from,to,options){const toPath=to||"archive.zip",sources=Array.isArray(from)?from:[from];return runCommand(["zip","-r",toPath,...sources],options)}export async function unzip(paths,options){const sources=Array.isArray(paths)?paths:[paths];return runCommand(["unzip","-o",...sources],options)}export function archive(paths,to,options){return zip(paths,to,options)}export function unarchive(paths,options){return unzip(paths,options)}export function compress(paths,to,options){return zip(paths,to,options)}export function decompress(paths,options){return unzip(paths,options)}export function gzipSync(data,options){return Bun.gzipSync(data,options)}export function gunzipSync(data){return Bun.gunzipSync(data)}export function deflateSync(data,options){return Bun.deflateSync(data,options)}export function inflateSync(data){return Bun.inflateSync(data)}
1
+ import{runCommand}from"@stacksjs/cli";export async function zip(from,to,options){const toPath=to||"archive.zip",sources=Array.isArray(from)?from:[from];return runCommand(["zip","-r",toPath,...sources],options)}export async function unzip(paths,options){const[first,...rest]=Array.isArray(paths)?paths:[paths];if(first===void 0)throw Error("unzip() needs at least one archive");let result=await runCommand(["unzip","-o",first],options);for(const source of rest){if(result.isErr)break;result=await runCommand(["unzip","-o",source],options)}return result}export function archive(paths,to,options){return zip(paths,to,options)}export function unarchive(paths,options){return unzip(paths,options)}export function compress(paths,to,options){return zip(paths,to,options)}export function decompress(paths,options){return unzip(paths,options)}export function gzipSync(data,options){return Bun.gzipSync(data,options)}export function gunzipSync(data){return Bun.gunzipSync(data)}export function deflateSync(data,options){return Bun.deflateSync(data,options)}export function inflateSync(data){return Bun.inflateSync(data)}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/storage",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.72",
5
+ "version": "0.75.74",
6
6
  "description": "The Stacks file system.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -61,20 +61,20 @@
61
61
  "prepublishOnly": "bun run build"
62
62
  },
63
63
  "dependencies": {
64
- "@stacksjs/arrays": "0.75.72",
65
- "@stacksjs/cli": "0.75.72",
66
- "@stacksjs/config": "0.75.72",
67
- "@stacksjs/env": "0.75.72",
68
- "@stacksjs/error-handling": "0.75.72",
69
- "@stacksjs/logging": "0.75.72",
70
- "@stacksjs/path": "0.75.72",
71
- "@stacksjs/strings": "0.75.72",
72
- "@stacksjs/ts-cloud": "^0.16.25",
64
+ "@stacksjs/arrays": "0.75.74",
65
+ "@stacksjs/cli": "0.75.74",
66
+ "@stacksjs/config": "0.75.74",
67
+ "@stacksjs/env": "0.75.74",
68
+ "@stacksjs/error-handling": "0.75.74",
69
+ "@stacksjs/logging": "0.75.74",
70
+ "@stacksjs/path": "0.75.74",
71
+ "@stacksjs/strings": "0.75.74",
72
+ "@stacksjs/ts-cloud": "^0.16.39",
73
73
  "@stacksjs/ts-xml": "^0.1.0",
74
74
  "ts-images": "^0.2.23"
75
75
  },
76
76
  "devDependencies": {
77
- "@stacksjs/types": "0.75.72",
77
+ "@stacksjs/types": "0.75.74",
78
78
  "better-dx": "^0.2.24"
79
79
  }
80
80
  }