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:
2026-06-21 13:16:59 +02:00
committed by codinget
parent 39b6cdb8ec
commit bba2c5c6f5
3 changed files with 121 additions and 22 deletions
+1
View File
@@ -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)
+8 -8
View File
@@ -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]))
+112 -14
View File
@@ -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" {