feat(tsconnect): add limits and change handlers to InMemoryFileOps (#43)
Reviewed-on: #43 Co-authored-by: Codinget <codinget@codi.moe> Co-committed-by: Codinget <codinget@codi.moe>
This commit was merged in pull request #43.
This commit is contained in:
@@ -50,3 +50,4 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
|
||||
- **`AGENTS.md` / `CLAUDE.md` and `AI_CHANGES.md`**: PR review instructions (use `tea`, interactive vs autonomous modes) and AI disclosure consolidation into `AI_CHANGES.md` authored by Claude Code (Claude Sonnet 4.6).
|
||||
- **`@webnet/vfs` — VFS package extraction**: Claude Code (Claude Sonnet 4.6) split the VFS abstraction (`AsyncVFS`, `Stat`, `VFSError`, `VFSErrorCode`) and its three implementations (`MemoryVFS`, `NodeVFS`, `FsaVFS`) out of `@webnet/drive` into a new standalone `@webnet/vfs` package, following the same pattern as the earlier `@webnet/transport` split from `@webnet/http`. `@webnet/drive` and `@webnet/test-app` now import directly from `@webnet/vfs`. The drive package's `./vfs/*` sub-exports were removed. The `NodeVFS` test suite moved with the implementation.
|
||||
- **`@webnet/tsconnect` — `FsaFileOps` limits and change notification**: Claude Code (Claude Sonnet 4.6) added `maxFiles`/`maxTotalSize`/`maxFileSize` limit getters/setters, `fileCount`/`totalSize`/`openFiles` getters, and `onChange`/`offChange` change-notification to `FsaFileOps`, mirroring the existing `InMemoryFileOps` API. File sizes are tracked in memory via a `#fileSizes` map that is updated on every `openWriter`/`write`/`remove`/`rename`; `createFromOpfs` now scans the directory at startup so pre-existing files count toward limits. The constructor was updated to accept an optional `{ maxFiles, maxTotalSize, maxFileSize, initialSizes }` options bag. Follow-up fixes (also Claude Sonnet 4.6): moved `#fileSizes.set` in `openWriter` to after all async FSA ops so a failure doesn't leave a phantom entry; fixed `rename` to stat and track previously-untracked files under their new name rather than letting them become invisible; documented the `stat`/`totalSize` lag during active writes.
|
||||
- **`@webnet/tsconnect`** - `InMemoryFileOps` limits and change handlers reviewed by Claude Code (Sonnet 4.6)
|
||||
|
||||
@@ -284,32 +284,32 @@ suite("InMemoryFileOps", () => {
|
||||
|
||||
suite("constructor seed data", () => {
|
||||
test("Uint8Array seed is readable as a stream", async () => {
|
||||
const ops = new InMemoryFileOps(new Map([["f", new Uint8Array([1, 2, 3])]]))
|
||||
const ops = new InMemoryFileOps({ data: new Map([["f", new Uint8Array([1, 2, 3])]]) })
|
||||
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
|
||||
assert.deepEqual(got, new Uint8Array([1, 2, 3]))
|
||||
})
|
||||
|
||||
test("array-of-chunks seed is readable as a stream", async () => {
|
||||
const ops = new InMemoryFileOps(
|
||||
new Map([["f", [new Uint8Array([1, 2]), new Uint8Array([3, 4])]]]),
|
||||
)
|
||||
const ops = new InMemoryFileOps({
|
||||
data: new Map([["f", [new Uint8Array([1, 2]), new Uint8Array([3, 4])]]]),
|
||||
})
|
||||
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
|
||||
assert.deepEqual(got, new Uint8Array([1, 2, 3, 4]))
|
||||
})
|
||||
|
||||
test("seed files appear in listFiles", () => {
|
||||
const ops = new InMemoryFileOps(
|
||||
new Map([
|
||||
const ops = new InMemoryFileOps({
|
||||
data: new Map([
|
||||
["a", new Uint8Array()],
|
||||
["b", new Uint8Array()],
|
||||
]),
|
||||
)
|
||||
})
|
||||
assert.deepEqual(ops.listFiles().sort(), ["a", "b"])
|
||||
})
|
||||
|
||||
test("seed data is independent of the source map", async () => {
|
||||
const seed = new Map<string, Uint8Array>([["f", new Uint8Array([1])]])
|
||||
const ops = new InMemoryFileOps(seed)
|
||||
const ops = new InMemoryFileOps({ data: seed })
|
||||
seed.delete("f")
|
||||
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
|
||||
assert.deepEqual(got, new Uint8Array([1]))
|
||||
|
||||
@@ -4,10 +4,91 @@ import type { IpnClient } from "./interface.js"
|
||||
export class InMemoryFileOps implements UserIPNFileOps {
|
||||
#map: Map<string, Uint8Array[]>
|
||||
#open: Set<string>
|
||||
#changeHandlers: Set<() => void>
|
||||
|
||||
constructor(data?: ReadonlyMap<string, Uint8Array | Uint8Array[]>) {
|
||||
#maxFiles: number
|
||||
#maxTotalSize: number
|
||||
#maxFileSize: number
|
||||
|
||||
get fileCount(): number {
|
||||
return this.#map.size
|
||||
}
|
||||
get openFiles(): number {
|
||||
return this.#open.size
|
||||
}
|
||||
get totalSize(): number {
|
||||
let size = 0
|
||||
for (const file of this.#map.values()) {
|
||||
for (const chunk of file) size += chunk.length
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
get maxFiles(): number {
|
||||
return this.#maxFiles
|
||||
}
|
||||
set maxFiles(maxFiles: number) {
|
||||
if (maxFiles < this.#map.size) throw new Error("Too many files")
|
||||
this.#maxFiles = maxFiles
|
||||
this.#change()
|
||||
}
|
||||
get maxTotalSize(): number {
|
||||
return this.#maxTotalSize
|
||||
}
|
||||
set maxTotalSize(maxTotalSize: number) {
|
||||
if (maxTotalSize < this.totalSize) throw new Error("Too much data")
|
||||
this.#maxTotalSize = maxTotalSize
|
||||
this.#change()
|
||||
}
|
||||
get maxFileSize(): number {
|
||||
return this.#maxFileSize
|
||||
}
|
||||
set maxFileSize(maxFileSize: number) {
|
||||
for (const file of this.#map.values()) {
|
||||
let size = 0
|
||||
for (const chunk of file) size += chunk.length
|
||||
if (maxFileSize < size) throw new Error("File too big")
|
||||
}
|
||||
this.#maxFileSize = maxFileSize
|
||||
this.#change()
|
||||
}
|
||||
|
||||
#change(): void {
|
||||
for (const handler of [...this.#changeHandlers]) {
|
||||
try {
|
||||
handler()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
onChange(handler: () => void): () => void {
|
||||
this.#changeHandlers.add(handler)
|
||||
return () => this.offChange(handler)
|
||||
}
|
||||
offChange(handler: () => void): void {
|
||||
this.#changeHandlers.delete(handler)
|
||||
}
|
||||
|
||||
constructor({
|
||||
data,
|
||||
maxFiles = Infinity,
|
||||
maxTotalSize = Infinity,
|
||||
maxFileSize = Infinity,
|
||||
}: {
|
||||
data?: ReadonlyMap<string, Uint8Array | Uint8Array[]>
|
||||
maxFiles?: number
|
||||
maxTotalSize?: number
|
||||
maxFileSize?: number
|
||||
} = {}) {
|
||||
this.#map = new Map()
|
||||
this.#open = new Set()
|
||||
this.#changeHandlers = new Set()
|
||||
|
||||
this.#maxFiles = maxFiles
|
||||
this.#maxTotalSize = maxTotalSize
|
||||
this.#maxFileSize = maxFileSize
|
||||
|
||||
if (data) {
|
||||
for (const [k, v] of data.entries()) {
|
||||
if (v instanceof Uint8Array) this.#map.set(k, [v])
|
||||
@@ -22,36 +103,51 @@ export class InMemoryFileOps implements UserIPNFileOps {
|
||||
}
|
||||
|
||||
if (!offset) {
|
||||
if (!this.#map.has(name) && this.#map.size >= this.#maxFiles)
|
||||
throw new Error("Too many files")
|
||||
this.#map.set(name, [])
|
||||
this.#open.add(name)
|
||||
this.#change()
|
||||
return
|
||||
}
|
||||
|
||||
const data = this.#map.get(name)
|
||||
if (!data) return "ENOENT"
|
||||
this.#open.add(name)
|
||||
let size = 0
|
||||
for (const [i, chunk] of data.entries()) {
|
||||
if (size === offset) {
|
||||
data.splice(i)
|
||||
return
|
||||
try {
|
||||
this.#open.add(name)
|
||||
let size = 0
|
||||
for (const [i, chunk] of data.entries()) {
|
||||
if (size === offset) {
|
||||
data.splice(i)
|
||||
return
|
||||
}
|
||||
if (size + chunk.length > offset) {
|
||||
data.splice(i)
|
||||
data.push(chunk.slice(0, offset - size))
|
||||
return
|
||||
}
|
||||
size += chunk.length
|
||||
}
|
||||
if (size + chunk.length > offset) {
|
||||
data.splice(i)
|
||||
data.push(chunk.slice(0, offset - size))
|
||||
return
|
||||
}
|
||||
size += chunk.length
|
||||
} finally {
|
||||
this.#change()
|
||||
}
|
||||
}
|
||||
|
||||
write(name: string, data: Uint8Array): void {
|
||||
if (!this.#open.has(name)) throw new Error("File not open")
|
||||
this.#map.get(name)!.push(data)
|
||||
const file = this.#map.get(name)!
|
||||
let size = 0
|
||||
for (const chunk of file) size += chunk.length
|
||||
if (size + data.length > this.#maxFileSize) throw new Error("File too big")
|
||||
if (this.totalSize + data.length > this.#maxTotalSize)
|
||||
throw new Error("Max total size exceeded")
|
||||
file.push(data)
|
||||
this.#change()
|
||||
}
|
||||
|
||||
closeWriter(name: string): void {
|
||||
if (!this.#open.delete(name)) throw new Error("File not open")
|
||||
this.#change()
|
||||
}
|
||||
|
||||
openReader(name: string): ReadableStream<Uint8Array> | "ENOENT" {
|
||||
@@ -73,6 +169,7 @@ export class InMemoryFileOps implements UserIPNFileOps {
|
||||
remove(name: string): void | "ENOENT" {
|
||||
if (this.#open.has(name)) throw new Error("File is open")
|
||||
if (!this.#map.delete(name)) return "ENOENT"
|
||||
this.#change()
|
||||
}
|
||||
|
||||
rename(oldPath: string, newName: string): void | "ENOENT" {
|
||||
@@ -83,6 +180,7 @@ export class InMemoryFileOps implements UserIPNFileOps {
|
||||
if (!data) return "ENOENT"
|
||||
this.#map.delete(oldPath)
|
||||
this.#map.set(newName, data)
|
||||
this.#change()
|
||||
}
|
||||
|
||||
stat(name: string): number | "ENOENT" {
|
||||
|
||||
Reference in New Issue
Block a user