@firesystem/s3
Version:
AWS S3 implementation of Virtual File System
1,140 lines (1,139 loc) • 35.2 kB
JavaScript
// src/S3FileSystem.ts
import {
normalizePath,
dirname,
basename,
join,
TypedEventEmitter,
FileSystemEvents,
} from "@firesystem/core";
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand,
DeleteObjectsCommand,
} from "@aws-sdk/client-s3";
var S3FileSystem = class {
constructor(config) {
this.watchers = [];
this.events = new TypedEventEmitter();
this.config = {
...config,
prefix: config.prefix || "/",
mode: config.mode || "strict",
};
if (this.config.prefix !== "/") {
this.config.prefix = normalizePath(this.config.prefix);
if (!this.config.prefix.endsWith("/")) {
this.config.prefix += "/";
}
this.config.prefix = this.config.prefix.substring(1);
}
this.client = new S3Client({
region: config.region,
credentials: config.credentials,
...config.clientOptions,
});
}
async initialize() {
const startTime = Date.now();
this.events.emit(FileSystemEvents.INITIALIZING, void 0);
try {
if (
this.config.mode === "strict" &&
this.config.prefix !== "/" &&
this.config.prefix !== ""
) {
await this.ensureDirectoryMarker(this.config.prefix);
}
this.events.emit(FileSystemEvents.INITIALIZED, {
duration: Date.now() - startTime,
});
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "initialize",
path: "/",
error,
});
throw error;
}
}
toS3Key(path) {
const normalized = normalizePath(path);
if (normalized === "/") {
return this.config.prefix === "/" ? "" : this.config.prefix;
}
const withoutLeadingSlash = normalized.substring(1);
if (this.config.prefix === "/" || this.config.prefix === "") {
return withoutLeadingSlash;
}
return this.config.prefix + withoutLeadingSlash;
}
fromS3Key(key) {
if (this.config.prefix === "/" || this.config.prefix === "") {
return "/" + key;
}
if (key.startsWith(this.config.prefix)) {
const withoutPrefix = key.substring(this.config.prefix.length);
return "/" + withoutPrefix;
}
return "/" + key;
}
async streamToString(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf-8");
}
async ensureDirectoryMarker(s3Key) {
const key = s3Key.endsWith("/") ? s3Key : s3Key + "/";
try {
await this.client.send(
new PutObjectCommand({
Bucket: this.config.bucket,
Key: key,
Body: "",
ContentType: "application/x-directory",
Metadata: {
"x-amz-meta-type": "directory",
"x-amz-meta-created": /* @__PURE__ */ new Date().toISOString(),
},
}),
);
} catch (error) {
throw new Error(`Failed to create directory marker: ${error}`);
}
}
async ensureParentExists(path) {
if (this.config.mode !== "strict") return;
const parent = dirname(path);
if (parent === "/" || parent === ".") return;
const s3Key = this.toS3Key(parent);
await this.ensureDirectoryMarker(s3Key);
}
parseS3Metadata(metadata) {
const result = {};
if (metadata) {
if (metadata["x-amz-meta-created"]) {
result.created = new Date(metadata["x-amz-meta-created"]);
}
if (metadata["x-amz-meta-modified"]) {
result.modified = new Date(metadata["x-amz-meta-modified"]);
}
if (metadata["x-amz-meta-type"]) {
result.type = metadata["x-amz-meta-type"];
}
if (metadata["x-amz-meta-custom"]) {
try {
result.fileMetadata = JSON.parse(metadata["x-amz-meta-custom"]);
} catch {}
}
}
return result;
}
createS3Metadata(type, metadata, created) {
const now = /* @__PURE__ */ new Date();
const s3Metadata = {
"x-amz-meta-type": type,
"x-amz-meta-created": (created || now).toISOString(),
"x-amz-meta-modified": now.toISOString(),
};
if (metadata) {
s3Metadata["x-amz-meta-custom"] = JSON.stringify(metadata);
}
return s3Metadata;
}
async readFile(path) {
const normalized = normalizePath(path);
const startTime = Date.now();
const operationId = `read-${normalized}-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "readFile",
path: normalized,
id: operationId,
});
try {
const s3Key = this.toS3Key(normalized);
const dirKey = s3Key.endsWith("/") ? s3Key : s3Key + "/";
try {
const dirCheck = await this.client.send(
new HeadObjectCommand({
Bucket: this.config.bucket,
Key: dirKey,
}),
);
if (dirCheck) {
throw new Error(
`EISDIR: illegal operation on a directory, read '${normalized}'`,
);
}
} catch (error) {
if (error.name !== "NoSuchKey" && !error.message?.includes("EISDIR")) {
throw error;
}
}
const response = await this.client.send(
new GetObjectCommand({
Bucket: this.config.bucket,
Key: s3Key,
}),
);
if (!response.Body) {
throw new Error(`File not found: ${normalized}`);
}
const parsedMetadata = this.parseS3Metadata(response.Metadata);
let type = parsedMetadata.type;
if (!type && this.config.mode === "lenient") {
type = s3Key.endsWith("/") ? "directory" : "file";
}
if (type === "directory" || s3Key.endsWith("/")) {
throw new Error(
`EISDIR: illegal operation on a directory, read '${normalized}'`,
);
}
let content;
if (parsedMetadata.fileMetadata?.isBinary) {
const base64Content = await this.streamToString(response.Body);
const buffer = Buffer.from(base64Content, "base64");
content = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
);
} else {
content = await this.streamToString(response.Body);
}
const result = {
path: normalized,
name: basename(normalized),
type: type || "file",
size: response.ContentLength || 0,
created:
parsedMetadata.created ||
new Date(response.LastModified || Date.now()),
modified:
parsedMetadata.modified ||
new Date(response.LastModified || Date.now()),
metadata: parsedMetadata.fileMetadata,
content,
};
this.events.emit(FileSystemEvents.FILE_READ, {
path: normalized,
size: result.size || 0,
});
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "readFile",
path: normalized,
id: operationId,
duration: Date.now() - startTime,
});
return result;
} catch (error) {
if (error.name === "NoSuchKey") {
const notFoundError = new Error(
`ENOENT: no such file or directory, open '${normalized}'`,
);
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "readFile",
path: normalized,
error: notFoundError,
});
throw notFoundError;
}
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "readFile",
path: normalized,
error,
});
throw error;
}
}
async writeFile(path, content, metadata) {
const normalized = normalizePath(path);
const startTime = Date.now();
const operationId = `write-${normalized}-${startTime}`;
let contentStr;
let isBinary = false;
let originalSize;
if (content === null || content === void 0) {
contentStr = "";
originalSize = 0;
} else if (typeof content === "string") {
contentStr = content;
originalSize = Buffer.byteLength(contentStr);
} else if (content instanceof ArrayBuffer) {
contentStr = Buffer.from(content).toString("base64");
isBinary = true;
originalSize = content.byteLength;
} else {
contentStr = JSON.stringify(content);
originalSize = Buffer.byteLength(contentStr);
}
const size = originalSize;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "writeFile",
path: normalized,
id: operationId,
});
this.events.emit(FileSystemEvents.FILE_WRITING, { path: normalized, size });
try {
if (this.config.mode === "strict") {
const parent = dirname(normalized);
if (parent !== "/" && parent !== ".") {
const parentExists = await this.exists(parent);
if (!parentExists) {
throw new Error(
`ENOENT: no such file or directory, open '${normalized}'`,
);
}
}
}
await this.ensureParentExists(normalized);
const s3Key = this.toS3Key(normalized);
let existingCreated;
try {
const existing = await this.client.send(
new HeadObjectCommand({
Bucket: this.config.bucket,
Key: s3Key,
}),
);
if (existing.Metadata?.["x-amz-meta-created"]) {
existingCreated = new Date(existing.Metadata["x-amz-meta-created"]);
}
} catch {}
const finalMetadata = isBinary
? { ...metadata, isBinary: true }
: metadata;
const s3Metadata = this.createS3Metadata(
"file",
finalMetadata,
existingCreated,
);
await this.client.send(
new PutObjectCommand({
Bucket: this.config.bucket,
Key: s3Key,
Body: contentStr,
ContentType: "text/plain",
Metadata: s3Metadata,
}),
);
const now = /* @__PURE__ */ new Date();
const result = {
path: normalized,
name: basename(normalized),
type: "file",
size,
created: existingCreated || new Date(s3Metadata["x-amz-meta-created"]),
modified: now,
metadata: finalMetadata,
content,
};
this.notifyWatchers({
type: existingCreated ? "updated" : "created",
path: normalized,
timestamp: now,
});
this.events.emit(FileSystemEvents.FILE_WRITTEN, {
path: normalized,
size,
});
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "writeFile",
path: normalized,
id: operationId,
duration: Date.now() - startTime,
});
return result;
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "writeFile",
path: normalized,
error,
});
throw error;
}
}
async deleteFile(path) {
const normalized = normalizePath(path);
const startTime = Date.now();
const operationId = `delete-${normalized}-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "deleteFile",
path: normalized,
id: operationId,
});
this.events.emit(FileSystemEvents.FILE_DELETING, { path: normalized });
try {
const exists = await this.exists(normalized);
if (!exists) {
throw new Error(
`ENOENT: no such file or directory, unlink '${normalized}'`,
);
}
const s3Key = this.toS3Key(normalized);
if (await this.isDirectory(normalized)) {
const entries = await this.readDir(normalized);
if (entries.length > 0) {
throw new Error(
`ENOTEMPTY: directory not empty, rmdir '${normalized}'`,
);
}
}
await this.client.send(
new DeleteObjectCommand({
Bucket: this.config.bucket,
Key: s3Key,
}),
);
this.notifyWatchers({
type: "deleted",
path: normalized,
timestamp: /* @__PURE__ */ new Date(),
});
this.events.emit(FileSystemEvents.FILE_DELETED, { path: normalized });
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "deleteFile",
path: normalized,
id: operationId,
duration: Date.now() - startTime,
});
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "deleteFile",
path: normalized,
error,
});
throw error;
}
}
async exists(path) {
const normalized = normalizePath(path);
try {
const s3Key = this.toS3Key(normalized);
try {
await this.client.send(
new HeadObjectCommand({
Bucket: this.config.bucket,
Key: s3Key,
}),
);
return true;
} catch {
if (this.config.mode === "lenient") {
const prefix = s3Key.endsWith("/") ? s3Key : s3Key + "/";
const response = await this.client.send(
new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: prefix,
MaxKeys: 1,
}),
);
return (response.Contents?.length || 0) > 0;
}
const dirKey = s3Key.endsWith("/") ? s3Key : s3Key + "/";
try {
await this.client.send(
new HeadObjectCommand({
Bucket: this.config.bucket,
Key: dirKey,
}),
);
return true;
} catch {
return false;
}
}
} catch {
return false;
}
}
async readDir(path) {
const normalized = normalizePath(path);
const startTime = Date.now();
const operationId = `readdir-${normalized}-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "readDir",
path: normalized,
id: operationId,
});
try {
if (normalized !== "/") {
const exists = await this.exists(normalized);
if (!exists) {
throw new Error(
`ENOENT: no such file or directory, scandir '${normalized}'`,
);
}
}
const s3Prefix = this.toS3Key(normalized);
const prefix =
s3Prefix === ""
? ""
: s3Prefix.endsWith("/")
? s3Prefix
: s3Prefix + "/";
const entries = [];
const seenPaths = /* @__PURE__ */ new Set();
let continuationToken;
do {
const response = await this.client.send(
new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: prefix,
Delimiter: "/",
ContinuationToken: continuationToken,
}),
);
if (response.CommonPrefixes) {
for (const commonPrefix of response.CommonPrefixes) {
if (commonPrefix.Prefix) {
const path2 = this.fromS3Key(commonPrefix.Prefix);
if (!seenPaths.has(path2)) {
seenPaths.add(path2);
entries.push({
path: path2,
name: basename(
path2.endsWith("/") ? path2.slice(0, -1) : path2,
),
type: "directory",
size: 0,
created: /* @__PURE__ */ new Date(),
modified: /* @__PURE__ */ new Date(),
});
}
}
}
}
if (response.Contents) {
for (const object of response.Contents) {
if (object.Key) {
if (object.Key.endsWith("/")) continue;
const relativePath = object.Key.substring(prefix.length);
if (relativePath.includes("/")) continue;
const path2 = this.fromS3Key(object.Key);
if (!seenPaths.has(path2)) {
seenPaths.add(path2);
entries.push({
path: path2,
name: basename(path2),
type: "file",
size: object.Size || 0,
created: object.LastModified || /* @__PURE__ */ new Date(),
modified: object.LastModified || /* @__PURE__ */ new Date(),
});
}
}
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
this.events.emit(FileSystemEvents.DIR_READ, {
path: normalized,
count: entries.length,
});
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "readDir",
path: normalized,
id: operationId,
duration: Date.now() - startTime,
});
return entries;
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "readDir",
path: normalized,
error,
});
throw error;
}
}
async mkdir(path, recursive) {
const normalized = normalizePath(path);
const startTime = Date.now();
const operationId = `mkdir-${normalized}-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "mkdir",
path: normalized,
id: operationId,
});
this.events.emit(FileSystemEvents.DIR_CREATING, {
path: normalized,
recursive: !!recursive,
});
try {
if (normalized === "/") {
throw new Error(`EEXIST: file already exists, mkdir '${normalized}'`);
}
if (await this.exists(normalized)) {
throw new Error(`EEXIST: file already exists, mkdir '${normalized}'`);
}
if (this.config.mode === "lenient") {
const now2 = /* @__PURE__ */ new Date();
const result2 = {
path: normalized,
name: basename(normalized),
type: "directory",
size: 0,
created: now2,
modified: now2,
};
this.notifyWatchers({
type: "created",
path: normalized,
timestamp: now2,
});
this.events.emit(FileSystemEvents.DIR_CREATED, { path: normalized });
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "mkdir",
path: normalized,
id: operationId,
duration: Date.now() - startTime,
});
return result2;
}
if (recursive) {
const parts = normalized.split("/").filter(Boolean);
let currentPath = "";
for (const part of parts) {
currentPath += "/" + part;
const s3Key = this.toS3Key(currentPath);
await this.ensureDirectoryMarker(s3Key);
}
} else {
const parent = dirname(normalized);
if (parent !== "/" && parent !== ".") {
const parentExists = await this.exists(parent);
if (!parentExists) {
throw new Error(
`ENOENT: no such file or directory, mkdir '${normalized}'`,
);
}
}
await this.ensureParentExists(normalized);
const s3Key = this.toS3Key(normalized);
await this.ensureDirectoryMarker(s3Key);
}
const now = /* @__PURE__ */ new Date();
const result = {
path: normalized,
name: basename(normalized),
type: "directory",
size: 0,
created: now,
modified: now,
};
this.notifyWatchers({
type: "created",
path: normalized,
timestamp: now,
});
this.events.emit(FileSystemEvents.DIR_CREATED, { path: normalized });
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "mkdir",
path: normalized,
id: operationId,
duration: Date.now() - startTime,
});
return result;
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "mkdir",
path: normalized,
error,
});
throw error;
}
}
async rmdir(path, recursive) {
const normalized = normalizePath(path);
const startTime = Date.now();
const operationId = `rmdir-${normalized}-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "rmdir",
path: normalized,
id: operationId,
});
this.events.emit(FileSystemEvents.DIR_DELETING, {
path: normalized,
recursive: !!recursive,
});
try {
if (normalized === "/") {
throw new Error(
`EBUSY: resource busy or locked, rmdir '${normalized}'`,
);
}
const exists = await this.exists(normalized);
if (!exists) {
throw new Error(
`ENOENT: no such file or directory, rmdir '${normalized}'`,
);
}
if (!(await this.isDirectory(normalized))) {
throw new Error(`ENOTDIR: not a directory, rmdir '${normalized}'`);
}
const s3Prefix = this.toS3Key(normalized);
const prefix = s3Prefix.endsWith("/") ? s3Prefix : s3Prefix + "/";
if (recursive) {
const objectsToDelete = [];
let continuationToken;
do {
const response = await this.client.send(
new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
}),
);
if (response.Contents) {
for (const object of response.Contents) {
if (object.Key) {
objectsToDelete.push({ Key: object.Key });
}
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
const batchSize = 1e3;
for (let i = 0; i < objectsToDelete.length; i += batchSize) {
const batch = objectsToDelete.slice(i, i + batchSize);
await this.client.send(
new DeleteObjectsCommand({
Bucket: this.config.bucket,
Delete: { Objects: batch },
}),
);
}
} else {
const response = await this.client.send(
new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: prefix,
MaxKeys: 2,
// We only need to know if there's more than the dir marker
}),
);
const hasContents =
(response.Contents?.length || 0) > 1 ||
(response.Contents?.length === 1 &&
!response.Contents[0].Key?.endsWith("/"));
if (hasContents) {
throw new Error(
`ENOTEMPTY: directory not empty, rmdir '${normalized}'`,
);
}
await this.client.send(
new DeleteObjectCommand({
Bucket: this.config.bucket,
Key: prefix,
}),
);
}
this.notifyWatchers({
type: "deleted",
path: normalized,
timestamp: /* @__PURE__ */ new Date(),
});
this.events.emit(FileSystemEvents.DIR_DELETED, { path: normalized });
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "rmdir",
path: normalized,
id: operationId,
duration: Date.now() - startTime,
});
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "rmdir",
path: normalized,
error,
});
throw error;
}
}
async rename(oldPath, newPath) {
const normalizedOld = normalizePath(oldPath);
const normalizedNew = normalizePath(newPath);
const startTime = Date.now();
const operationId = `rename-${normalizedOld}-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "rename",
path: normalizedOld,
id: operationId,
});
try {
if (!(await this.exists(normalizedOld))) {
throw new Error(
`ENOENT: no such file or directory, rename '${normalizedOld}' -> '${normalizedNew}'`,
);
}
if (await this.exists(normalizedNew)) {
throw new Error(
`EEXIST: file already exists, rename '${normalizedOld}' -> '${normalizedNew}'`,
);
}
const newParent = dirname(normalizedNew);
if (
newParent !== "/" &&
newParent !== "." &&
!(await this.exists(newParent))
) {
throw new Error(
`ENOENT: no such file or directory, rename '${normalizedOld}' -> '${normalizedNew}'`,
);
}
const oldEntry = await this.readFile(normalizedOld);
if (oldEntry.type === "file") {
await this.writeFile(
normalizedNew,
oldEntry.content,
oldEntry.metadata,
);
await this.deleteFile(normalizedOld);
} else {
await this.move([normalizedOld], dirname(normalizedNew));
}
const result = await this.readFile(normalizedNew);
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "rename",
path: normalizedOld,
id: operationId,
duration: Date.now() - startTime,
});
return result;
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "rename",
path: normalizedOld,
error,
});
throw error;
}
}
async move(sourcePaths, targetPath) {
const normalizedTarget = normalizePath(targetPath);
const startTime = Date.now();
const operationId = `move-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "move",
path: sourcePaths[0],
id: operationId,
});
try {
if (!(await this.exists(normalizedTarget))) {
throw new Error(
`ENOENT: no such file or directory, open '${normalizedTarget}'`,
);
}
if (!(await this.isDirectory(normalizedTarget))) {
throw new Error(`ENOTDIR: not a directory, open '${normalizedTarget}'`);
}
for (const sourcePath of sourcePaths) {
const normalizedSource = normalizePath(sourcePath);
const targetFilePath = join(
normalizedTarget,
basename(normalizedSource),
);
const sourceEntry = await this.readFile(normalizedSource);
await this.writeFile(
targetFilePath,
sourceEntry.content,
sourceEntry.metadata,
);
await this.deleteFile(normalizedSource);
}
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "move",
path: sourcePaths[0],
id: operationId,
duration: Date.now() - startTime,
});
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "move",
path: sourcePaths[0],
error,
});
throw error;
}
}
async copy(sourcePath, targetPath) {
const normalizedSource = normalizePath(sourcePath);
const normalizedTarget = normalizePath(targetPath);
const startTime = Date.now();
const operationId = `copy-${normalizedSource}-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "copy",
path: normalizedSource,
id: operationId,
});
try {
if (await this.isDirectory(normalizedSource)) {
throw new Error(
`EISDIR: illegal operation on a directory, open '${normalizedSource}'`,
);
}
const sourceEntry = await this.readFile(normalizedSource);
const result = await this.writeFile(
normalizedTarget,
sourceEntry.content,
sourceEntry.metadata,
);
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "copy",
path: normalizedSource,
id: operationId,
duration: Date.now() - startTime,
});
return result;
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "copy",
path: normalizedSource,
error,
});
throw error;
}
}
watch(pattern, callback) {
const id = Math.random().toString(36).substr(2, 9);
const listener = { id, pattern, callback };
this.watchers.push(listener);
return {
dispose: () => {
const index = this.watchers.findIndex((w) => w.id === id);
if (index !== -1) {
this.watchers.splice(index, 1);
}
},
};
}
notifyWatchers(event) {
for (const watcher of this.watchers) {
if (
watcher.pattern === "*" ||
event.path.includes(watcher.pattern) ||
this.matchGlob(event.path, watcher.pattern)
) {
watcher.callback(event);
}
}
}
matchGlob(path, pattern) {
if (pattern === "*") {
return path.split("/").length === 2;
}
if (pattern === "**") {
return true;
}
let regex = pattern
.replace(/\*\*/g, "___DOUBLE_STAR___")
.replace(/\*/g, "[^/]*")
.replace(/___DOUBLE_STAR___/g, ".*")
.replace(/\?/g, ".")
.replace(/\//g, "\\/");
return new RegExp("^" + regex + "$").test(path);
}
async isDirectory(path) {
const normalized = normalizePath(path);
const s3Key = this.toS3Key(normalized);
const dirKey = s3Key.endsWith("/") ? s3Key : s3Key + "/";
try {
const response = await this.client.send(
new HeadObjectCommand({
Bucket: this.config.bucket,
Key: dirKey,
}),
);
return response.Metadata?.["x-amz-meta-type"] === "directory";
} catch {
const response = await this.client.send(
new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: dirKey,
MaxKeys: 1,
}),
);
return (response.Contents?.length || 0) > 0;
}
}
async stat(path) {
const normalized = normalizePath(path);
try {
if (await this.isDirectory(normalized)) {
const s3Key = this.toS3Key(normalized);
const dirKey = s3Key.endsWith("/") ? s3Key : s3Key + "/";
try {
const response = await this.client.send(
new HeadObjectCommand({
Bucket: this.config.bucket,
Key: dirKey,
}),
);
const metadata = this.parseS3Metadata(response.Metadata);
return {
path: normalized,
size: 0,
type: "directory",
created:
metadata.created || new Date(response.LastModified || Date.now()),
modified:
metadata.modified ||
new Date(response.LastModified || Date.now()),
};
} catch {
return {
path: normalized,
size: 0,
type: "directory",
created: /* @__PURE__ */ new Date(),
modified: /* @__PURE__ */ new Date(),
};
}
}
const entry = await this.readFile(normalized);
return {
path: entry.path,
size: entry.size || 0,
type: entry.type,
created: entry.created || /* @__PURE__ */ new Date(),
modified: entry.modified || /* @__PURE__ */ new Date(),
};
} catch (error) {
if (error.message?.includes("EISDIR")) {
return {
path: normalized,
size: 0,
type: "directory",
created: /* @__PURE__ */ new Date(),
modified: /* @__PURE__ */ new Date(),
};
}
throw new Error(`Failed to stat ${normalized}: ${error}`);
}
}
async glob(pattern) {
const results = [];
const seenPaths = /* @__PURE__ */ new Set();
const prefix = pattern.split("*")[0].replace(/^\//, "");
const s3Prefix =
this.config.prefix === "/" ? prefix : this.config.prefix + prefix;
let continuationToken;
do {
const response = await this.client.send(
new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: s3Prefix,
ContinuationToken: continuationToken,
}),
);
if (response.Contents) {
for (const object of response.Contents) {
if (object.Key) {
const path = this.fromS3Key(object.Key);
if (!object.Key.endsWith("/") && this.matchGlob(path, pattern)) {
seenPaths.add(path);
}
if (pattern === "*" || pattern === "**") {
const parts = path.split("/").filter(Boolean);
for (let i = 1; i <= parts.length - 1; i++) {
const dirPath = "/" + parts.slice(0, i).join("/");
if (this.matchGlob(dirPath, pattern)) {
seenPaths.add(dirPath);
}
}
}
}
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
return Array.from(seenPaths).sort();
}
async clear() {
const startTime = Date.now();
const operationId = `clear-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "clear",
path: "/",
id: operationId,
});
try {
const objectsToDelete = [];
let continuationToken;
do {
const response = await this.client.send(
new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: this.config.prefix === "/" ? "" : this.config.prefix,
ContinuationToken: continuationToken,
}),
);
if (response.Contents) {
for (const object of response.Contents) {
if (object.Key) {
objectsToDelete.push({ Key: object.Key });
}
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
const batchSize = 1e3;
for (let i = 0; i < objectsToDelete.length; i += batchSize) {
const batch = objectsToDelete.slice(i, i + batchSize);
await this.client.send(
new DeleteObjectsCommand({
Bucket: this.config.bucket,
Delete: { Objects: batch },
}),
);
}
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "clear",
path: "/",
id: operationId,
duration: Date.now() - startTime,
});
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "clear",
path: "/",
error,
});
throw error;
}
}
async size() {
let totalSize = 0;
let continuationToken;
do {
const response = await this.client.send(
new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: this.config.prefix === "/" ? "" : this.config.prefix,
ContinuationToken: continuationToken,
}),
);
if (response.Contents) {
for (const object of response.Contents) {
totalSize += object.Size || 0;
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
return totalSize;
}
// Implementações temporárias para compilar - S3 será implementado no futuro
async canModify(path) {
return true;
}
async canCreateIn(parentPath) {
return true;
}
};
export { S3FileSystem };
//# sourceMappingURL=index.js.map