10 Commits
Author SHA1 Message Date
sugoidogo 8bc2c7d70c refactor
Release / release (push) Successful in 24s
2026-02-16 15:57:22 -08:00
sugoidogo e99fc35e25 explicity truncate files when not keeping existing data
Release / release (push) Successful in 23s
2026-02-15 20:51:17 -08:00
sugoidogo bbd975feb6 remove close/abort override
Release / release (push) Successful in 23s
2026-02-15 20:22:23 -08:00
sugoidogo 1bd153de5b use single fd for all operations 2026-02-15 19:57:19 -08:00
sugoidogo a1838bc153 fix missing file close
Release / release (push) Successful in 23s
2026-02-15 18:48:28 -08:00
sugoidogo 65e8e64079 cleaner file implementation
Release / release (push) Successful in 23s
2026-02-09 13:41:04 -08:00
sugoidogo 579c4bf131 switch to importable-types-web
Release / release (push) Successful in 23s
2026-02-09 07:09:30 -08:00
sugoidogo 1cc6906cc9 switch from @types/web to web-filesystem-types
Release / release (push) Successful in 22s
2026-01-20 07:46:16 -08:00
sugoidogo 4884247282 fix imports for node 20+
Release / release (push) Successful in 23s
2025-12-31 02:20:58 -08:00
sugoidogo 1fa35ba82e include mime type in getFile
Release / release (push) Successful in 24s
2025-12-30 12:03:26 -08:00
8 changed files with 150 additions and 103 deletions
+19 -14
View File
@@ -1,36 +1,41 @@
{ {
"name": "node-file-system-adapter", "name": "@sugoidogo/node-file-system-adapter",
"version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "node-file-system-adapter", "name": "@sugoidogo/node-file-system-adapter",
"version": "0.0.0",
"dependencies": { "dependencies": {
"mime": "^4.1.0" "mime": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.3", "@sugoidogo/importable-types-web": "^1.0.0",
"@types/web": "^0.0.310", "@types/node": "^25.0.9",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
"node_modules/@sugoidogo/importable-types-web": {
"version": "1.0.0",
"resolved": "https://gitea.sugoidogo.com/api/packages/sugoidogo/npm/%40sugoidogo%2Fimportable-types-web/-/1.0.0/importable-types-web-1.0.0.tgz",
"integrity": "sha512-MqWkQ/WsllO7GiQjqWiFB3ZzsDwJwGPXIWkyd0E24d3CMcI0FKGgsOn6G/RVjCO9k2d/aRlzxqVmyuPAYOZ8tQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"undici-types": "^7.16.0"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.0.3", "version": "25.0.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/web": {
"version": "0.0.310",
"resolved": "https://registry.npmjs.org/@types/web/-/web-0.0.310.tgz",
"integrity": "sha512-XN++V7UZGEeRg8MXJBkzMqmcAWoCRN1UV/zpQxg9YnJQ9pIPSuzEG7oidC+nXi/J8pmfb3UgFv+TjEDzMiNnRg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/mime": { "node_modules/mime": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
+3 -2
View File
@@ -1,5 +1,6 @@
{ {
"name": "@sugoidogo/node-file-system-adapter", "name": "@sugoidogo/node-file-system-adapter",
"version": "0.0.0",
"main": "dist/NodeFileSystemDirectoryHandle.js", "main": "dist/NodeFileSystemDirectoryHandle.js",
"files": [ "files": [
"dist", "dist",
@@ -15,8 +16,8 @@
"mime": "^4.1.0" "mime": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.3", "@sugoidogo/importable-types-web": "^1.0.0",
"@types/web": "^0.0.310", "@types/node": "^25.0.9",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }
+16 -9
View File
@@ -1,14 +1,21 @@
import NodeFileSystemDirectoryHandle from '../dist/NodeFileSystemDirectoryHandle.js' import NodeFileSystemDirectoryHandle from '../src/NodeFileSystemDirectoryHandle.ts'
const textText="Hello World!"
const cwd_directory_handle = new NodeFileSystemDirectoryHandle('.') const cwd_directory_handle = new NodeFileSystemDirectoryHandle('.')
const test_directory_handle=await cwd_directory_handle.getDirectoryHandle('test',{'create':true}) const test_directory_handle = await cwd_directory_handle.getDirectoryHandle('test', { 'create': true })
const file_handle = await test_directory_handle.getFileHandle('test.txt', { 'create': true }) for (let i = 0; i < 10000; i++) {
const writable = await file_handle.createWritable() const file_handle = await test_directory_handle.getFileHandle(i+'.txt', { 'create': true })
writable.write('Hello World!') let writable = await file_handle.createWritable()
writable.close() await writable.write(textText+textText)
const file = await file_handle.getFile() await writable.close()
const file_text = await file.text() writable = await file_handle.createWritable()
console.log(file_text) await writable.write(textText)
await writable.close()
const file = await file_handle.getFile()
console.log(file.webkitRelativePath)
const text = await file.text()
if (text !== textText) throw new Error("failed to read back written data")
}
const result = await cwd_directory_handle.getFileHandle('test/test.txt').catch(error => 'blocked path traversal') const result = await cwd_directory_handle.getFileHandle('test/test.txt').catch(error => 'blocked path traversal')
if (typeof result === 'string') console.log(result) if (typeof result === 'string') console.log(result)
else console.error('path traversal not blocked') else console.error('path traversal not blocked')
+32 -25
View File
@@ -1,25 +1,36 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as asyncfs from 'node:fs/promises'; import * as asyncfs from 'node:fs/promises';
import * as path from 'node:path'; import * as Path from 'node:path';
import NodeFileSystemHandle from './NodeFileSystemHandle.js'; import NodeFileSystemHandle from './NodeFileSystemHandle.ts';
import NodeFileSystemFileHandle from './NodeFileSystemFileHandle.js'; import NodeFileSystemFileHandle from './NodeFileSystemFileHandle.ts';
import type {
FileSystemDirectoryHandle,
FileSystemGetDirectoryOptions,
FileSystemDirectoryHandleAsyncIterator,
FileSystemGetFileOptions,
FileSystemRemoveOptions,
FileSystemHandle
} from '@sugoidogo/importable-types-web'
export default class NodeFileSystemDirectoryHandle extends NodeFileSystemHandle implements FileSystemDirectoryHandle { export default class NodeFileSystemDirectoryHandle extends NodeFileSystemHandle implements FileSystemDirectoryHandle {
declare kind: "directory"; declare kind: "directory";
constructor(fpath: string, options: FileSystemGetDirectoryOptions = {}) { constructor(path: string, options: FileSystemGetDirectoryOptions & { origin?: NodeFileSystemDirectoryHandle } = {}) {
super() super(path, options)
fpath = path.normalize(fpath) try {
if (!fs.existsSync(fpath) && options.create) fs.mkdirSync(fpath) const stats = fs.statSync(this.path)
if (!fs.lstatSync(fpath).isDirectory()) throw new Error('not a directory') if (!stats.isDirectory()) throw new Error("tried to open file as directory: " + this.path)
this.path = fpath } catch (error) {
if (options.create) fs.mkdirSync(this.path)
else throw error
}
} }
entries(): FileSystemDirectoryHandleAsyncIterator<[string, NodeFileSystemDirectoryHandle | NodeFileSystemFileHandle]> { entries(): FileSystemDirectoryHandleAsyncIterator<[string, NodeFileSystemDirectoryHandle | NodeFileSystemFileHandle]> {
const entries: Array<[string, NodeFileSystemDirectoryHandle | NodeFileSystemFileHandle]> = [] const entries: Array<[string, NodeFileSystemDirectoryHandle | NodeFileSystemFileHandle]> = []
fs.readdirSync(this.path).forEach(name => { fs.readdirSync(this.path).forEach(name => {
if (fs.lstatSync(name).isDirectory()) { if (fs.lstatSync(name).isDirectory()) {
entries.push([name, new NodeFileSystemDirectoryHandle(path.join(this.path, name))]) entries.push([name, new NodeFileSystemDirectoryHandle(Path.join(this.path, name), { origin: this.origin || this })])
} else { } else {
entries.push([name, new NodeFileSystemFileHandle(path.join(this.path, name))]) entries.push([name, new NodeFileSystemFileHandle(Path.join(this.path, name), { origin: this.origin || this })])
} }
}) })
return entries[Symbol.asyncIterator] return entries[Symbol.asyncIterator]
@@ -31,37 +42,33 @@ export default class NodeFileSystemDirectoryHandle extends NodeFileSystemHandle
const values: Array<NodeFileSystemDirectoryHandle | NodeFileSystemFileHandle> = []; const values: Array<NodeFileSystemDirectoryHandle | NodeFileSystemFileHandle> = [];
fs.readdirSync(this.path).forEach(name => { fs.readdirSync(this.path).forEach(name => {
if (fs.lstatSync(name).isDirectory()) { if (fs.lstatSync(name).isDirectory()) {
values.push(new NodeFileSystemDirectoryHandle(path.join(this.path, name))) values.push(new NodeFileSystemDirectoryHandle(Path.join(this.path, name), { origin: this.origin || this }))
} else { } else {
values.push(new NodeFileSystemFileHandle(path.join(this.path, name))) values.push(new NodeFileSystemFileHandle(Path.join(this.path, name), { origin: this.origin || this }))
} }
}) })
return values[Symbol.asyncIterator] return values[Symbol.asyncIterator]
} }
#getValidatedPath(name: string) { #getValidatedPath(name: string) {
name = path.normalize(name) name = Path.normalize(name)
if (name.includes(path.sep)) throw new TypeError('Invalid Filename') if (name.includes(Path.sep)) throw new TypeError('Invalid Filename')
return path.join(this.path, name) return Path.join(this.path, name)
} }
async getDirectoryHandle(name: string, options: FileSystemGetDirectoryOptions = {}): Promise<NodeFileSystemDirectoryHandle> { async getDirectoryHandle(name: string, options: FileSystemGetDirectoryOptions = {}): Promise<NodeFileSystemDirectoryHandle> {
Object.assign(options, { origin: this.origin || this })
return new NodeFileSystemDirectoryHandle(this.#getValidatedPath(name), options) return new NodeFileSystemDirectoryHandle(this.#getValidatedPath(name), options)
} }
async getFileHandle(name: string, options: FileSystemGetFileOptions = {}): Promise<NodeFileSystemFileHandle> { async getFileHandle(name: string, options: FileSystemGetFileOptions = {}): Promise<NodeFileSystemFileHandle> {
Object.assign(options, { origin: this.origin || this })
return new NodeFileSystemFileHandle(this.#getValidatedPath(name), options) return new NodeFileSystemFileHandle(this.#getValidatedPath(name), options)
} }
async removeEntry(name: string, options: FileSystemRemoveOptions = { recursive: false }): Promise<void> { async removeEntry(name: string, options: FileSystemRemoveOptions = { recursive: false }): Promise<void> {
return asyncfs.rm(this.#getValidatedPath(name), { recursive: options.recursive, force: options.recursive }) return asyncfs.rm(this.#getValidatedPath(name), { recursive: options.recursive, force: options.recursive })
} }
async resolve(possibleDescendant: FileSystemHandle): Promise<string[] | null> { async resolve(possibleDescendant: FileSystemHandle): Promise<string[] | null> {
if (typeof possibleDescendant !== typeof this) return null if (!(possibleDescendant instanceof NodeFileSystemHandle)) return null
let thisName = this.path if (!possibleDescendant.path.startsWith(this.path)) return null
let possibleDescendantName = possibleDescendant.name return possibleDescendant.path.substring(this.path.length + 1).split(Path.sep)
if (!path.isAbsolute(this.path)) thisName = path.join(process.cwd(), this.path)
if (!path.isAbsolute(possibleDescendant.name)) possibleDescendantName = path.join(process.cwd(), possibleDescendant.name)
if (!possibleDescendantName.startsWith(thisName)) return null
possibleDescendantName = possibleDescendantName.substring(thisName.length - 1)
if (possibleDescendantName[0] == path.sep) possibleDescendantName = possibleDescendantName.substring(1)
return possibleDescendantName.split(path.sep)
} }
[Symbol.asyncIterator] = this.entries [Symbol.asyncIterator] = this.entries
} }
+29 -16
View File
@@ -1,27 +1,40 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as asyncfs from 'node:fs/promises'; import * as asyncfs from 'node:fs/promises';
import * as path from 'node:path'; import * as Path from "node:path"
import NodeFileSystemHandle from './NodeFileSystemHandle.js'; import NodeFileSystemHandle from './NodeFileSystemHandle.ts';
import NodeFileSystemWritableFileStream from './NodeFileSystemWriteableFileStream.js'; import NodeFileSystemWritableFileStream from './NodeFileSystemWriteableFileStream.ts';
import mime from 'mime'
import type {
FileSystemFileHandle,
FileSystemGetFileOptions,
FileSystemCreateWritableOptions,
FileSystemWritableFileStream,
File as WebFile
} from '@sugoidogo/importable-types-web'
import type NodeFileSystemDirectoryHandle from './NodeFileSystemDirectoryHandle.ts';
export default class NodeFileSystemFileHandle extends NodeFileSystemHandle implements FileSystemFileHandle { export default class NodeFileSystemFileHandle extends NodeFileSystemHandle implements FileSystemFileHandle {
declare kind: "file"; declare kind: "file";
constructor(fpath: string, options: FileSystemGetFileOptions={}) { constructor(path: string, options: FileSystemGetFileOptions & { origin?: NodeFileSystemDirectoryHandle } = {}) {
super() super(path,options)
fpath = path.normalize(fpath) let flags = fs.constants.O_RDWR
if (!fs.existsSync(fpath) && options.create) fs.writeFileSync(fpath, '') if (options.create) flags |= fs.constants.O_CREAT
if (fs.lstatSync(fpath).isDirectory()) new Error('not a file') fs.closeSync(fs.openSync(this.path, flags))
this.path = fpath
} }
async createWritable(options: FileSystemCreateWritableOptions = {}): Promise<FileSystemWritableFileStream> { async createWritable(options: FileSystemCreateWritableOptions = {}): Promise<FileSystemWritableFileStream> {
return new NodeFileSystemWritableFileStream(this.path,options) let flags = fs.constants.O_WRONLY
const fileHandle = await asyncfs.open(this.path, flags)
if (!options.keepExistingData) await fileHandle.truncate(0)
return new NodeFileSystemWritableFileStream(fileHandle)
} }
async getFile(): Promise<File> { async getFile(): Promise<WebFile> {
const type = mime.getType(this.path.split('.').pop())
const stats = await asyncfs.stat(this.path) const stats = await asyncfs.stat(this.path)
return asyncfs.readFile(this.path).then(buffer => Object.assign(new Blob([buffer]), { const relativePathSegments = await this.origin.resolve(this)
lastModified: stats.mtimeMs, return Object.assign(await fs.openAsBlob(this.path, { "type": type }), {
name: this.path, name: this.name,
webkitRelativePath: this.path webkitRelativePath: relativePathSegments.join(Path.sep),
})) lastModified: stats.mtimeMs
}) as any
} }
} }
+15 -8
View File
@@ -1,15 +1,22 @@
import * as path from 'node:path' import * as Path from 'node:path'
import type {
FileSystemHandle,
FileSystemHandleKind
} from '@sugoidogo/importable-types-web'
import type NodeFileSystemDirectoryHandle from './NodeFileSystemDirectoryHandle';
export default class NodeFileSystemHandle implements FileSystemHandle { export default class NodeFileSystemHandle implements FileSystemHandle {
kind: FileSystemHandleKind; kind: FileSystemHandleKind;
path: string; path: string;
get name() { return path.basename(this.path) } origin: NodeFileSystemDirectoryHandle
get name() { return Path.basename(this.path) }
constructor(path: string, options: { origin?: NodeFileSystemDirectoryHandle } = {}) {
if (!Path.isAbsolute(path)) path = Path.resolve(path)
this.path = path
this.origin = options.origin
}
async isSameEntry(other: FileSystemHandle): Promise<boolean> { async isSameEntry(other: FileSystemHandle): Promise<boolean> {
if (typeof other != typeof this) return false if (!(other instanceof NodeFileSystemHandle)) return false
let thisName = this.name return this.path === other.path
let otherName = other.name
if (!path.isAbsolute(this.name)) thisName = path.join(process.cwd(), this.name)
if (!path.isAbsolute(other.name)) otherName = path.join(process.cwd(), other.name)
return thisName === otherName
} }
} }
+34 -28
View File
@@ -1,38 +1,44 @@
import * as fs from 'node:fs'
import * as asyncfs from 'node:fs/promises'
import * as path from 'node:path'
import { Writable } from 'node:stream' import { Writable } from 'node:stream'
import type {
FileSystemWritableFileStream,
FileSystemWriteChunkType,
WriteParams
} from '@sugoidogo/importable-types-web'
import type { FileHandle } from 'node:fs/promises'
function isWriteParams(data: FileSystemWriteChunkType): data is WriteParams {
return ["write", "seek", "truncate"].includes((data as WriteParams).type)
}
export default class NodeFileSystemWritableFileStream extends WritableStream implements FileSystemWritableFileStream { export default class NodeFileSystemWritableFileStream extends WritableStream implements FileSystemWritableFileStream {
#seek_position = 0 #position = 0
#name: string #fileHandle: FileHandle
constructor(name: string, options: FileSystemCreateWritableOptions={}) { constructor(fileHandle: FileHandle) {
name = path.normalize(name) const writeStream = fileHandle.createWriteStream({ "encoding": "binary" })
super(Writable.toWeb(fs.createWriteStream(name))) const writeableStream = Writable.toWeb(writeStream)
this.#name = name super(writeableStream)
if(!options.keepExistingData) this.truncate(0) this.#fileHandle = fileHandle
} }
async seek(position: number): Promise<void> { async seek(position: number): Promise<void> {
this.#seek_position = position if (this.locked) throw new Error("stream is locked")
this.#position = position
} }
truncate(size: number): Promise<void> { async truncate(size: number): Promise<void> {
return asyncfs.truncate(this.#name, size) if (this.locked) throw new Error("stream is locked")
return this.#fileHandle.truncate(size)
} }
async write(wdata: FileSystemWriteChunkType): Promise<void> { async write(data: FileSystemWriteChunkType): Promise<void> {
if (wdata instanceof Blob) wdata = await wdata.bytes() if (this.locked) throw new Error("stream is locked")
if (typeof wdata === 'string') wdata = new TextEncoder().encode(wdata) let offset: number, length: number, position=this.#position
let wsize: number = null if (isWriteParams(data)) {
let pseek_position = this.#seek_position if (data.type === "seek") return this.seek(data.position)
if ((wdata as WriteParams).type) { if (data.type === "truncate") return this.truncate(data.size)
const { type, position, size, data } = (wdata as WriteParams) if (typeof data.position === "number") position = data.position
if (type === 'truncate') return asyncfs.truncate(this.#name, size) length = data.size
if (position) this.#seek_position = position data = data.data
if (type === 'seek') return
if (size) wsize = size
wdata = data
} }
const file = await asyncfs.open(this.#name,'r+') if (typeof data === 'string') data = new TextEncoder().encode(data)
await file.write((wdata as Uint8Array), null, wsize, this.#seek_position) const { bytesWritten } = await this.#fileHandle.write(data as any, offset, length, position)
this.#seek_position = pseek_position this.#position=position+bytesWritten
} }
} }
+2 -1
View File
@@ -7,7 +7,8 @@
"sourceMap": true, "sourceMap": true,
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"outDir": "dist" "outDir": "dist",
"rewriteRelativeImportExtensions": true
}, },
"include": ["src"] "include": ["src"]
} }