Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
# @suaveplan/storage-browser-opfs - Browser OPFS AbstractStorage adapter for @suaveplan/storage: origin-scoped persistent file storage via navigator.storage.getDirectory(), AbortSignal everywhere, strict key sanitisation, native Web Streams, native partial reads + partial writes, DOMException → typed OpfsError mapping, full OTel surface.
Browser-tier `AbstractStorage` adapter backed by the Origin Private File System (OPFS). Provides persistent, origin-scoped key-value storage for browser consumers (vault snapshots, drive chunk caches, in-app i18n catalogues, SQLite-WASM backing files), with full `AbstractStorage` conformance, native Web Streams, native partial reads + partial writes, AbortSignal threading, Zod-validated options, structured `OpfsError` boundary, and a complete OpenTelemetry surface.
<!-- begin:auto-docs -->
## Documentation tree
- [filename-codec](./src/filename-codec/filename-codec.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [filename-codec](./src/filename-codec/filename-codec.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - opfs-storage.ops — Hosts pure / nearly-pure helpers…
- [opfs-storage.errors](./src/opfs-storage/opfs-storage.errors.md) — Encapsulates the mapping between WHATWG `DOMException` shapes thrown by the OPFS API and the typed `OpfsError` codes th…
- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - opfs-storage.ops — Hosts pure / nearly-pure helpers…
- [opfs-storage.ops](./src/opfs-storage/opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
- [opfs-storage.streams](./src/opfs-storage/opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-opfs` package can throw. The registry uses…
<!-- end:auto-docs -->
- [filename-codec](./src/filename-codec/filename-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opfs-storage.errors](./src/opfs-storage/opfs-storage.errors.md) — Encapsulates the mapping between WHATWG `DOMException` shapes thrown by the OPFS API and the typed `OpfsError` codes th…
- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opfs-storage.ops](./src/opfs-storage/opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
- [opfs-storage.streams](./src/opfs-storage/opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-opfs` package can throw. The registry uses…
## Install
```bash
bun add @suaveplan/storage-browser-opfs
```
## Quick start
```ts
import {
createOpfsStorage,
isOpfsAvailable,
} from "@suaveplan/storage-browser-opfs";
if (!isOpfsAvailable()) {
throw new Error("This browser does not support OPFS");
}
const storage = createOpfsStorage({ directory: "myapp" });
await storage.set("user/42", new TextEncoder().encode("hello"));
const back = await storage.get("user/42");
```
## Factory integration
```ts
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
import { createStorage } from "@suaveplan/storage/factory";
registerOpfsProvider();
const storage = createStorage({ type: "opfs", config: { directory: "myapp" } });
```
The package does NOT self-register at module-evaluation time — call `registerOpfsProvider()` explicitly so `sideEffects: false` bundlers can tree-shake the factory path when not used.
## Capabilities
| Capability | Value | Notes |
|---|---|---|
| `partialReads` | `true` | Via `File.slice(...).arrayBuffer()`. |
| `partialWrites` | `true` | Via `FileSystemWritableFileStream.seek()`. |
| `nativeStreams` | `true` | `File.stream()` for read; buffered close-on-commit for write. |
| `atomicWrites` | `false` | Close-as-commit semantics within a single realm; no cross-realm locking. |
| `contentAddressable` | `false` | Keys are caller-supplied strings. |
## Modules
- [`src/error-codes.ts`](./src/error-codes.md) — `OPFS_CODES` registry + `OpfsError`.
- [`src/key-sanitizer/key-sanitizer.ts`](./src/key-sanitizer/key-sanitizer.md) — strict key boundary.
- [`src/filename-codec/filename-codec.ts`](./src/filename-codec/filename-codec.md) — caller-key ↔ OPFS-filename round-trip.
- [`src/opfs-adapter/opfs-adapter.ts`](./src/opfs-adapter/opfs-adapter.md) — `defaultOpfs()` + `isOpfsAvailable()`.
- [`src/opfs-storage/opfs-storage.ts`](./src/opfs-storage/opfs-storage.md) — the `OpfsStorage` class.
- [`src/opfs-storage/opfs-storage.errors.ts`](./src/opfs-storage/opfs-storage.errors.md) — DOMException mapping.
- [`src/opfs-storage/opfs-storage.ops.ts`](./src/opfs-storage/opfs-storage.ops.md) — internal helpers.
- [`src/opfs-storage/opfs-storage.streams.ts`](./src/opfs-storage/opfs-storage.streams.md) — `ReadableStream`/`WritableStream` builders.
- [`src/otel/otel.ts`](./src/otel/otel.md) — OpenTelemetry instrument set.
- [`src/registry/registry.ts`](./src/registry/registry.md) — `registerOpfsProvider()`.
- [`src/in-memory-opfs/in-memory-opfs.ts`](./src/in-memory-opfs/in-memory-opfs.md) — real-implementation test double, exposed under `./in-memory-opfs` for downstream test packages.
- [`src/types/opfs-like.types.ts`](./src/types/opfs-like.types.md) — structural OPFS surface.
- [`src/types/opfs-storage.types.ts`](./src/types/opfs-storage.types.md) — option boundary.
## License
MIT.

View File

@@ -0,0 +1,113 @@
{
"name": "@suaveplan/storage-browser-opfs",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Browser OPFS AbstractStorage adapter for @suaveplan/storage: origin-scoped persistent file storage via navigator.storage.getDirectory(), AbortSignal everywhere, strict key sanitisation, native Web Streams, native partial reads + partial writes, DOMException → typed OpfsError mapping, full OTel surface.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"browser": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./opfs-storage": {
"import": {
"types": "./dist/opfs-storage/index.d.ts",
"default": "./dist/opfs-storage/opfs-storage.js"
}
},
"./key-sanitizer": {
"import": {
"types": "./dist/key-sanitizer/index.d.ts",
"default": "./dist/key-sanitizer/key-sanitizer.js"
}
},
"./filename-codec": {
"import": {
"types": "./dist/filename-codec/index.d.ts",
"default": "./dist/filename-codec/filename-codec.js"
}
},
"./opfs-adapter": {
"import": {
"types": "./dist/opfs-adapter/index.d.ts",
"default": "./dist/opfs-adapter/opfs-adapter.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/index.d.ts",
"default": "./dist/otel/otel.js"
}
},
"./registry": {
"import": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/registry.js"
}
},
"./in-memory-opfs": {
"import": {
"types": "./dist/in-memory-opfs/index.d.ts",
"default": "./dist/in-memory-opfs/in-memory-opfs.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,5 @@
/** Maximum length of a caller-supplied key. */
export const MAX_KEY_LENGTH = 1024;
/** Default OPFS sub-directory under the origin root. */
export const DEFAULT_DIRECTORY = "storage";

View File

@@ -0,0 +1,54 @@
# `error-codes` — OPFS error registry
## Purpose
Single source of truth for every error code the `@suaveplan/storage-browser-opfs` package can throw. The registry uses `defineCodes("OPFS", [...])` from `@suaveplan/error` so each code is namespaced (`OPFS.NOT_FOUND`, `OPFS.QUOTA_EXCEEDED`, etc.), frozen at module load, and surfaced via a typed `OpfsErrorCode` union. Centralising the registry keeps the boundary error vocabulary tiny, prevents drift between the implementation and the spec, and lets callers pattern-match on a stable string code rather than parsing message text.
## Features
- Nine frozen codes covering every observable failure mode: `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`.
- `OpfsError` extends `SuaveplanError<OpfsErrorCode>` with an optional `key` discriminant.
- `cause` chain preserved from upstream `DOMException` instances.
- TypeScript-narrowed `OpfsErrorCode` union for exhaustive switches.
## Usage: Basic Example
```ts
import { OPFS_CODES, OpfsError } from "@suaveplan/storage-browser-opfs";
throw new OpfsError(OPFS_CODES.NOT_FOUND, "missing entry", { key: "user/42" });
```
## Usage: Advanced Example
```ts
import {
OPFS_CODES,
OpfsError,
type OpfsErrorCode,
} from "@suaveplan/storage-browser-opfs";
function handle(err: unknown): "retry" | "fail" | "ignore" {
if (!(err instanceof OpfsError)) return "fail";
const code: OpfsErrorCode = err.code;
switch (code) {
case OPFS_CODES.NOT_FOUND:
return "ignore";
case OPFS_CODES.QUOTA_EXCEEDED:
case OPFS_CODES.IO_FAILED:
return "retry";
default:
return "fail";
}
}
```
## API Reference
- `OPFS_CODES` — frozen `Readonly<Record<CodeName, "OPFS.CodeName">>` produced by `defineCodes`.
- `OpfsErrorCode` — the union type of every member of `OPFS_CODES`.
- `OpfsError extends SuaveplanError<OpfsErrorCode>` — constructor `(code, message, options?)` where `options.key` carries the offending key for correlation and `options.cause` chains the original error.
## Implementation Notes
The registry is frozen at module load — adding a new code requires changing this file, which forces the spec delta to be updated in lockstep. `OpfsError.name` is set to `"OpfsError"` so devtools display useful labels. The `key` discriminant is the only optional field on the error class because no other piece of error context is universal across the nine paths; callers needing more should consult `cause`.

View File

@@ -0,0 +1,43 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const OPFS_CODES = defineCodes("OPFS", [
"NOT_FOUND",
"PERMISSION_DENIED",
"QUOTA_EXCEEDED",
"UNAVAILABLE",
"INVALID_KEY",
"KEY_TOO_LONG",
"INVALID_OPTIONS",
"ABORTED",
"IO_FAILED",
] as const);
export type OpfsErrorCode = (typeof OPFS_CODES)[keyof typeof OPFS_CODES];
export interface OpfsErrorOptions extends SuaveplanErrorOptions {
key?: string;
}
/**
* Structured error for OPFS storage operations.
*
* Carries an optional `key` discriminant so callers can correlate failures
* with the offending key without parsing message text.
*/
export class OpfsError extends SuaveplanError<OpfsErrorCode> {
public readonly key: string | undefined;
public constructor(
code: OpfsErrorCode,
message: string,
options?: OpfsErrorOptions
) {
super(code, message, options);
this.key = options?.key;
this.name = "OpfsError";
}
}

View File

@@ -0,0 +1,69 @@
# `filename-codec` — caller key ↔ OPFS filename
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OPFS filenames inherit filesystem-level constraints that caller keys must not (case-insensitivity on some platforms, no embedded `/` separator within a single entry name, byte-encoding ambiguity across browsers). This module wraps each key in RFC 4648 base64url over its UTF-8 byte representation, producing OPFS-safe filenames that round-trip every caller key — including unicode, mixed scripts, slashes, and any byte the caller threw at us. Storing the codec as a separate module keeps the algorithm independently testable (property-based round-trip suite) and replaceable (a future hash-addressed mode can plug in without touching `OpfsStorage`).
## Features
- RFC 4648 base64url with `+`→`-`, `/`→`_`, padding stripped.
- UTF-8 fatal decode rejects malformed byte sequences with a typed `OpfsError`.
- Round-trip invariant proven by a `fast-check` property test (200 random inputs).
- Output regex: `^[A-Za-z0-9_-]*$` — accepted by every OPFS implementation.
- Pure / synchronous / zero workspace dependencies.
## Usage: Basic Example
```ts
import {
decodeFilename,
encodeFilename,
} from "@suaveplan/storage-browser-opfs/filename-codec";
const filename = encodeFilename("orders/2026/05.json"); // "b3JkZXJzLzIwMjYvMDUuanNvbg"
const original = decodeFilename(filename); // "orders/2026/05.json"
```
## Usage: Advanced Example
```ts
import {
decodeFilename,
encodeFilename,
OPFS_CODES,
OpfsError,
} from "@suaveplan/storage-browser-opfs";
function listKeysSafely(entries: { name: string }[]): string[] {
const out: string[] = [];
for (const entry of entries) {
try {
out.push(decodeFilename(entry.name));
} catch (err) {
// Skip entries that aren't ours — a foreign tool may have written
// something to the OPFS directory we share.
if (
err instanceof OpfsError &&
err.code === OPFS_CODES.INVALID_KEY
) {
continue;
}
throw err;
}
}
return out;
}
```
## API Reference
- `encodeFilename(key: string): string` — converts to base64url-without-padding.
- `decodeFilename(filename: string): string` — throws `OpfsError(OPFS.INVALID_KEY)` for malformed base64 or invalid UTF-8.
## Implementation Notes
The codec is deliberately byte-identical to the WHATWG spec for base64url to keep filenames stable across runtimes — the same OPFS directory written from Chrome must round-trip from Safari and Firefox. `btoa`/`atob` are universally available in browsers and in Node.js 20+, so no polyfill is needed at runtime. The fatal `TextDecoder` makes the codec self-healing: a foreign tool that writes non-base64 entries to the OPFS directory will be skipped by `decodeFilename`-using callers rather than corrupting their key view.

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { OPFS_CODES, type OpfsError } from "../error-codes.js";
import { decodeFilename, encodeFilename } from "./filename-codec.js";
describe("filename-codec", () => {
it("round-trips ASCII keys", { timeout: 5000 }, () => {
expect(decodeFilename(encodeFilename("a"))).toBe("a");
expect(decodeFilename(encodeFilename("orders/2026/05.json"))).toBe(
"orders/2026/05.json"
);
});
it("round-trips unicode keys", { timeout: 5000 }, () => {
expect(decodeFilename(encodeFilename("héllo/wörld"))).toBe(
"héllo/wörld"
);
expect(decodeFilename(encodeFilename("日本語"))).toBe("日本語");
expect(decodeFilename(encodeFilename("🚀/⭐/✨"))).toBe("🚀/⭐/✨");
});
it("produces filenames containing only base64url-safe chars", {
timeout: 5000,
}, () => {
const safe = /^[A-Za-z0-9_-]*$/;
for (const k of ["a/b", "héllo", "🚀", "日本語"]) {
expect(safe.test(encodeFilename(k))).toBe(true);
}
});
it("strips base64 padding", { timeout: 5000 }, () => {
// "a" → "YQ==" in base64; codec strips trailing '=' characters.
expect(encodeFilename("a").endsWith("=")).toBe(false);
});
it("rejects malformed base64url filenames with INVALID_KEY", {
timeout: 5000,
}, () => {
try {
decodeFilename("!!!not-base64!!!");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
it("rejects filenames that decode to invalid UTF-8", {
timeout: 5000,
}, () => {
// 0xC3 is a UTF-8 lead byte expecting a continuation byte 0x80-0xBF.
// Encoding a lone 0xC3 yields invalid UTF-8 when decoded.
const filename = encodeFilenameFromBytes(new Uint8Array([0xc3]));
try {
decodeFilename(filename);
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
it("fast-check: decode∘encode is identity for arbitrary strings", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.string(), (s) => {
expect(decodeFilename(encodeFilename(s))).toBe(s);
}),
{ numRuns: 200 }
);
});
});
// Helper: encode arbitrary bytes through the same base64url-without-padding
// shape used by `encodeFilename`. Used to construct invalid-UTF-8 test inputs.
function encodeFilenameFromBytes(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i] as number);
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}

View File

@@ -0,0 +1,61 @@
import { OPFS_CODES, OpfsError } from "../error-codes.js";
const ENCODER = new TextEncoder();
const DECODER = new TextDecoder("utf-8", { fatal: true });
/**
* Encode an arbitrary caller key into an OPFS-safe filename.
*
* Uses RFC 4648 base64url over the UTF-8 byte representation of `key`. The
* resulting filename contains only `[A-Za-z0-9_-]` characters, which OPFS
* accepts across every browser implementation, never contains `/`, and is
* unaffected by case-insensitive filesystems (because the codec preserves
* every byte regardless of underlying filesystem semantics).
*/
export function encodeFilename(key: string): string {
const bytes = ENCODER.encode(key);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i] as number);
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
/**
* Decode an OPFS-safe filename back to the original caller key.
*
* Inverse of {@link encodeFilename}. Throws
* `OpfsError(OPFS.INVALID_KEY)` when the filename is malformed base64url
* or contains a byte sequence that is not valid UTF-8.
*/
export function decodeFilename(filename: string): string {
const padded =
filename.replace(/-/g, "+").replace(/_/g, "/") +
"==".slice(0, (4 - (filename.length % 4)) % 4);
let binary: string;
try {
binary = atob(padded);
} catch (err) {
throw new OpfsError(
OPFS_CODES.INVALID_KEY,
"filename is not valid base64url",
{ cause: err as Error }
);
}
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
try {
return DECODER.decode(bytes);
} catch (err) {
throw new OpfsError(
OPFS_CODES.INVALID_KEY,
"filename does not decode to valid UTF-8",
{ cause: err as Error }
);
}
}

View File

@@ -0,0 +1,4 @@
export {
decodeFilename,
encodeFilename,
} from "./filename-codec.js";

View File

@@ -0,0 +1,60 @@
# `in-memory-opfs` — real `OpfsLike` test double
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A faithful in-memory implementation of the `OpfsLike` structural interface and its companion directory / file / writable-stream surfaces. The double exists because the test suite for this package — and for any downstream consumer of `OpfsStorage` — needs to exercise the full OPFS API without booting a real browser. CLAUDE.md forbids `vi.mock` / `vi.stubGlobal`, so the test double is a real implementation that the test code constructs and injects via the `opfs` option. The implementation preserves bytes verbatim, honours per-file quotas, supports the seek-then-write pattern `OpfsStorage.setPartial` exercises, and emits the same `DOMException`-shaped errors (`NotFoundError`, `QuotaExceededError`, `NotAllowedError`) the real browser throws.
Exposing the double under the `./in-memory-opfs` subpath lets downstream packages (e.g. test fixtures, contract conformance suites) reuse it without re-implementing the (subtle) WHATWG semantics around `keepExistingData`, `seek`, and stream lifecycle.
## Features
- Full coverage of every `OpfsLike` / `OpfsDirectoryHandle` / `OpfsFileHandle` / `OpfsFile` / `OpfsWritableStream` method that `OpfsStorage` calls.
- Faithful `seek()` semantics: position cursor on the underlying buffer, growable on overflow, zero-fill on sparse extension.
- `keepExistingData: true` honoured for the seek-then-patch flow.
- Configurable per-file quota; over-quota writes throw `QuotaExceededDomException` matching the real browser's error name.
- Injectable clock for deterministic `lastModified` in tests.
## Usage: Basic Example
```ts
import { createInMemoryOpfs } from "@suaveplan/storage-browser-opfs/in-memory-opfs";
import { OpfsStorage } from "@suaveplan/storage-browser-opfs";
const opfs = createInMemoryOpfs();
const storage = new OpfsStorage({ opfs });
await storage.set("k", new Uint8Array([1, 2, 3]));
```
## Usage: Advanced Example
```ts
import {
createInMemoryOpfs,
QuotaExceededDomException,
} from "@suaveplan/storage-browser-opfs/in-memory-opfs";
import { OpfsStorage, OPFS_CODES, OpfsError } from "@suaveplan/storage-browser-opfs";
const opfs = createInMemoryOpfs({ quotaBytes: 16, now: () => 1_700_000_000_000 });
const storage = new OpfsStorage({ opfs });
try {
await storage.set("k", new Uint8Array(32));
} catch (err) {
if (err instanceof OpfsError) {
// err.code === OPFS_CODES.QUOTA_EXCEEDED, err.cause instanceof QuotaExceededDomException
}
}
```
## API Reference
- `createInMemoryOpfs(options?: InMemoryOpfsOptions): OpfsLike` — `options.quotaBytes` (default 100 MiB), `options.now` (default `Date.now`).
- `InMemoryOpfsDirectoryHandle` — the concrete root-directory class.
- `NotFoundDomException`, `QuotaExceededDomException`, `NotAllowedDomException` — `Error` subclasses with the real browser's `.name` strings.
## Implementation Notes
The double's buffer grows on demand (`Uint8Array` reallocation) to support sparse writes from `seek` past the current end-of-file — the gap is implicitly zero-filled because `Uint8Array` defaults to zero. The writable stream wraps a real `WritableStream<Uint8Array>` so callers get genuine back-pressure and abort semantics; `seek` mutates the cursor state shared between the writer closure and the wrapper class. Per-file quota is enforced at write time so over-quota writes fail synchronously, matching the real browser. `entries()` yields children in insertion order — the WHATWG spec doesn't guarantee an order, but stable iteration keeps the test suite deterministic.

View File

@@ -0,0 +1,292 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { OpfsDirectoryHandle } from "../types/opfs-like.types.js";
import {
createInMemoryOpfs,
InMemoryOpfsDirectoryHandle,
NotAllowedDomException,
NotFoundDomException,
QuotaExceededDomException,
} from "./in-memory-opfs.js";
async function rootOf(opfs: { getDirectory(): Promise<OpfsDirectoryHandle> }) {
return opfs.getDirectory();
}
describe("in-memory-opfs", () => {
it("creates and reads back file contents", { timeout: 5000 }, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
const w = await fh.createWritable();
const writer = w.getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
await writer.close();
const file = await fh.getFile();
expect(new Uint8Array(await file.arrayBuffer())).toEqual(
new Uint8Array([1, 2, 3])
);
expect(file.size).toBe(3);
});
it("returns NotFoundError when getFileHandle without create", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
try {
await root.getFileHandle("missing");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NotFoundDomException);
expect((err as Error).name).toBe("NotFoundError");
}
});
it("returns NotFoundError when getDirectoryHandle without create", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
try {
await root.getDirectoryHandle("missing");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NotFoundDomException);
}
});
it("rejects when getFileHandle hits an existing directory name", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
await root.getDirectoryHandle("d", { create: true });
try {
await root.getFileHandle("d", { create: true });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NotFoundDomException);
}
});
it("rejects when getDirectoryHandle hits an existing file name", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
await root.getFileHandle("f", { create: true });
try {
await root.getDirectoryHandle("f", { create: true });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NotFoundDomException);
}
});
it("creates nested directories and persists entries", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
const sub = await root.getDirectoryHandle("sub", { create: true });
await sub.getFileHandle("nested", { create: true });
const sub2 = await root.getDirectoryHandle("sub");
const names: string[] = [];
for await (const [name] of sub2.entries()) names.push(name);
expect(names).toEqual(["nested"]);
});
it("removeEntry deletes by name and surfaces NotFoundError otherwise", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
await root.getFileHandle("a", { create: true });
await root.removeEntry("a");
try {
await root.removeEntry("a");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(NotFoundDomException);
}
});
it("seek + write produces the correct sparse layout", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
const w = await fh.createWritable();
await w.seek(5);
const writer = w.getWriter();
await writer.write(new Uint8Array([1, 1, 1]));
await writer.close();
const file = await fh.getFile();
const got = new Uint8Array(await file.arrayBuffer());
expect(got).toEqual(new Uint8Array([0, 0, 0, 0, 0, 1, 1, 1]));
});
it("rejects negative or non-integer seek positions", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
const w = await fh.createWritable();
await expect(w.seek(-1)).rejects.toBeInstanceOf(RangeError);
await expect(w.seek(1.5)).rejects.toBeInstanceOf(RangeError);
await w.close();
});
it("keepExistingData: true preserves previous bytes", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
let w = await fh.createWritable();
let writer = w.getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
await writer.close();
w = await fh.createWritable({ keepExistingData: true });
await w.seek(1);
writer = w.getWriter();
await writer.write(new Uint8Array([9]));
await writer.close();
const got = new Uint8Array(await (await fh.getFile()).arrayBuffer());
expect(got).toEqual(new Uint8Array([1, 9, 3]));
});
it("File.slice yields a view of the underlying bytes", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
const w = await fh.createWritable();
const writer = w.getWriter();
await writer.write(new Uint8Array([1, 2, 3, 4, 5]));
await writer.close();
const file = await fh.getFile();
const sliced = file.slice(1, 4);
expect(sliced.size).toBe(3);
expect(new Uint8Array(await sliced.arrayBuffer())).toEqual(
new Uint8Array([2, 3, 4])
);
});
it("File.stream emits the file contents as a single chunk", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
const w = await fh.createWritable();
const writer = w.getWriter();
await writer.write(new Uint8Array([1, 2]));
await writer.close();
const file = await fh.getFile();
const reader = file.stream().getReader();
const chunks: Uint8Array[] = [];
for (;;) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
}
expect(chunks.length).toBe(1);
expect(chunks[0]).toEqual(new Uint8Array([1, 2]));
});
it("rejects writes that exceed the configured quota", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs({ quotaBytes: 4 });
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
const w = await fh.createWritable();
const writer = w.getWriter();
try {
await writer.write(new Uint8Array([1, 2, 3, 4, 5]));
expect.fail("expected throw");
} catch (err) {
expect((err as Error).name).toBe("QuotaExceededError");
}
// Writer's lock is released automatically when the underlying stream
// errors; calling `releaseLock` defensively is safe.
try {
writer.releaseLock();
} catch {
// already released
}
});
it("uses the injected clock for lastModified", {
timeout: 5000,
}, async () => {
let t = 1000;
const opfs = createInMemoryOpfs({ now: () => t });
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
t = 2000;
const w = await fh.createWritable();
const writer = w.getWriter();
await writer.write(new Uint8Array([1]));
await writer.close();
const file = await fh.getFile();
expect(file.lastModified).toBe(2000);
});
it("getDirectory returns an InMemoryOpfsDirectoryHandle", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
expect(root).toBeInstanceOf(InMemoryOpfsDirectoryHandle);
});
it("exposes a constructable NotAllowedDomException", {
timeout: 5000,
}, () => {
const e = new NotAllowedDomException("nope");
expect(e.name).toBe("NotAllowedError");
});
it("locked + abort + close + getWriter forward to the underlying stream", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
const w = await fh.createWritable();
const writer = w.getWriter();
expect(w.locked).toBe(true);
writer.releaseLock();
await w.abort();
// Re-creating works after abort.
const w2 = await fh.createWritable();
await w2.close();
});
it("QuotaExceededDomException is a constructable error", {
timeout: 5000,
}, () => {
const e = new QuotaExceededDomException("nope");
expect(e.name).toBe("QuotaExceededError");
});
it("File.slice with default arguments returns full file size", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await rootOf(opfs);
const fh = await root.getFileHandle("a", { create: true });
const w = await fh.createWritable();
const writer = w.getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
await writer.close();
const file = await fh.getFile();
const full = file.slice();
expect(full.size).toBe(3);
});
});

View File

@@ -0,0 +1,279 @@
import type {
OpfsDirectoryHandle,
OpfsFile,
OpfsFileHandle,
OpfsLike,
OpfsWritableStream,
} from "../types/opfs-like.types.js";
/**
* Real in-memory implementation of the `OpfsLike` surface. Used by this
* package's own tests and exported under `./in-memory-opfs` so downstream
* test packages can run against the same fixture without re-implementing
* the (subtle) WHATWG semantics.
*
* Every method is a real implementation — there are no `vi.fn` mocks. The
* fixture preserves bytes, honours `lastModified`, throws real
* `DOMException`-shaped errors via the {@link NotFoundDomException} and
* {@link QuotaExceededDomException} sentinel classes, and provides the
* `seek()`/`write()`/`close()` semantics `OpfsStorage` exercises.
*/
export class NotFoundDomException extends Error {
public override readonly name = "NotFoundError";
}
export class QuotaExceededDomException extends Error {
public override readonly name = "QuotaExceededError";
}
export class NotAllowedDomException extends Error {
public override readonly name = "NotAllowedError";
}
interface FileEntry {
bytes: Uint8Array;
lastModified: number;
}
class InMemoryFile implements OpfsFile {
public constructor(
private readonly entry: FileEntry,
private readonly offset = 0,
private readonly end = entry.bytes.byteLength
) {}
public get size(): number {
return this.end - this.offset;
}
public get lastModified(): number {
return this.entry.lastModified;
}
public async arrayBuffer(): Promise<ArrayBuffer> {
const view = this.entry.bytes.subarray(this.offset, this.end);
const out = new ArrayBuffer(view.byteLength);
new Uint8Array(out).set(view);
return out;
}
public slice(start = 0, end = this.size): OpfsFile {
const absStart = this.offset + Math.max(0, Math.min(start, this.size));
const absEnd =
this.offset +
Math.max(absStart - this.offset, Math.min(end, this.size));
return new InMemoryFile(this.entry, absStart, absEnd);
}
public stream(): ReadableStream<Uint8Array> {
const view = this.entry.bytes.subarray(this.offset, this.end);
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(view);
controller.close();
},
});
}
}
interface WritableState {
cursor: number;
buffer: Uint8Array;
closed: boolean;
}
class InMemoryWritable implements OpfsWritableStream {
readonly #state: WritableState;
readonly #commit: (bytes: Uint8Array) => void;
readonly #stream: WritableStream<Uint8Array>;
readonly #quota: number;
public constructor(
initial: Uint8Array,
commit: (bytes: Uint8Array) => void,
quota: number
) {
this.#commit = commit;
this.#quota = quota;
this.#state = {
cursor: 0,
buffer: new Uint8Array(initial),
closed: false,
};
const state = this.#state;
const commitFn = this.#commit;
const quotaCap = this.#quota;
this.#stream = new WritableStream<Uint8Array>({
write(chunk) {
writeAt(state, state.cursor, chunk, quotaCap);
state.cursor += chunk.byteLength;
},
close() {
state.closed = true;
commitFn(state.buffer);
},
abort() {
state.closed = true;
},
});
}
public get locked(): boolean {
return this.#stream.locked;
}
public abort(reason?: unknown): Promise<void> {
return this.#stream.abort(reason);
}
public close(): Promise<void> {
return this.#stream.close();
}
public getWriter(): WritableStreamDefaultWriter<Uint8Array> {
return this.#stream.getWriter();
}
public async seek(position: number): Promise<void> {
if (position < 0 || !Number.isInteger(position)) {
throw new RangeError(`invalid seek position: ${position}`);
}
this.#state.cursor = position;
}
}
function writeAt(
state: WritableState,
offset: number,
chunk: Uint8Array,
quota: number
): void {
const end = offset + chunk.byteLength;
if (end > quota) {
throw new QuotaExceededDomException(
`write exceeds in-memory OPFS quota of ${quota} bytes`
);
}
if (end > state.buffer.byteLength) {
const grown = new Uint8Array(end);
grown.set(state.buffer, 0);
state.buffer = grown;
}
state.buffer.set(chunk, offset);
}
class InMemoryFileHandle implements OpfsFileHandle {
public readonly kind = "file" as const;
public constructor(
public readonly name: string,
public readonly entry: FileEntry,
private readonly now: () => number,
private readonly quota: number
) {}
public async getFile(): Promise<OpfsFile> {
return new InMemoryFile(this.entry);
}
public async createWritable(options?: {
keepExistingData?: boolean;
}): Promise<OpfsWritableStream> {
const keep = options?.keepExistingData === true;
const initial = keep
? new Uint8Array(this.entry.bytes)
: new Uint8Array(0);
const entry = this.entry;
const now = this.now;
return new InMemoryWritable(
initial,
(bytes) => {
entry.bytes = bytes;
entry.lastModified = now();
},
this.quota
);
}
}
export interface InMemoryOpfsOptions {
/** Maximum total bytes any single file may hold. Default 100 MiB. */
quotaBytes?: number;
/** Injectable clock for `lastModified`. */
now?: () => number;
}
export class InMemoryOpfsDirectoryHandle implements OpfsDirectoryHandle {
public readonly kind = "directory" as const;
public readonly children = new Map<
string,
InMemoryFileHandle | InMemoryOpfsDirectoryHandle
>();
public constructor(
public readonly name: string,
private readonly now: () => number,
private readonly quota: number
) {}
public async getFileHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsFileHandle> {
const existing = this.children.get(name);
if (existing && existing.kind === "file") return existing;
if (existing) {
throw new NotFoundDomException(
`${name} is a directory, not a file`
);
}
if (options?.create !== true) {
throw new NotFoundDomException(`${name} not found`);
}
const handle = new InMemoryFileHandle(
name,
{ bytes: new Uint8Array(0), lastModified: this.now() },
this.now,
this.quota
);
this.children.set(name, handle);
return handle;
}
public async getDirectoryHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsDirectoryHandle> {
const existing = this.children.get(name);
if (existing && existing.kind === "directory") return existing;
if (existing) {
throw new NotFoundDomException(
`${name} is a file, not a directory`
);
}
if (options?.create !== true) {
throw new NotFoundDomException(`${name} not found`);
}
const handle = new InMemoryOpfsDirectoryHandle(
name,
this.now,
this.quota
);
this.children.set(name, handle);
return handle;
}
public async removeEntry(
name: string,
_options?: { recursive?: boolean }
): Promise<void> {
if (!this.children.has(name)) {
throw new NotFoundDomException(`${name} not found`);
}
this.children.delete(name);
}
public async *entries(): AsyncIterable<
[string, OpfsFileHandle | OpfsDirectoryHandle]
> {
for (const [name, child] of this.children.entries()) {
yield [name, child];
}
}
}
export function createInMemoryOpfs(
options: InMemoryOpfsOptions = {}
): OpfsLike {
const quota = options.quotaBytes ?? 100 * 1024 * 1024;
const now = options.now ?? (() => Date.now());
const root = new InMemoryOpfsDirectoryHandle("", now, quota);
return {
getDirectory(): Promise<OpfsDirectoryHandle> {
return Promise.resolve(root);
},
};
}

View File

@@ -0,0 +1,8 @@
export type { InMemoryOpfsOptions } from "./in-memory-opfs.js";
export {
createInMemoryOpfs,
InMemoryOpfsDirectoryHandle,
NotAllowedDomException,
NotFoundDomException,
QuotaExceededDomException,
} from "./in-memory-opfs.js";

View File

@@ -0,0 +1,61 @@
// Side-effect import: ensures `defineCodes` registers the OPFS.* registry
// even when subpath consumers never reach this file.
import "./error-codes.js";
export {
OPFS_CODES,
OpfsError,
type OpfsErrorCode,
type OpfsErrorOptions,
} from "./error-codes.js";
export {
decodeFilename,
encodeFilename,
} from "./filename-codec/filename-codec.js";
export {
createInMemoryOpfs,
InMemoryOpfsDirectoryHandle,
type InMemoryOpfsOptions,
NotAllowedDomException,
NotFoundDomException,
QuotaExceededDomException,
} from "./in-memory-opfs/in-memory-opfs.js";
export { sanitizeKey } from "./key-sanitizer/key-sanitizer.js";
export { defaultOpfs, isOpfsAvailable } from "./opfs-adapter/opfs-adapter.js";
export {
assertNotAborted,
isNotFoundError,
mapOpfsError,
} from "./opfs-storage/opfs-storage.errors.js";
export { createOpfsStorage, OpfsStorage } from "./opfs-storage/opfs-storage.js";
export {
assertNonNegInt,
listAllKeys,
metadataFromFile,
paginateKeys,
tryGetFileHandle,
} from "./opfs-storage/opfs-storage.ops.js";
export {
buildReadStream,
buildWriteStream,
} from "./opfs-storage/opfs-storage.streams.js";
export {
getInstruments,
type RecordOpInput,
recordOp,
resetInstruments,
type StorageBrowserOpfsInstruments,
type StorageBrowserOpfsOp,
type StorageBrowserOpfsOpOutcome,
setStorageBrowserOpfsMeterProvider,
} from "./otel/otel.js";
export { registerOpfsProvider } from "./registry/registry.js";
export type {
OpfsDirectoryHandle,
OpfsFile,
OpfsFileHandle,
OpfsLike,
OpfsStorageOptions,
OpfsWritableStream,
} from "./types/index.js";
export { OpfsStorageOptionsSchema } from "./types/index.js";

View File

@@ -0,0 +1 @@
export { sanitizeKey } from "./key-sanitizer.js";

View File

@@ -0,0 +1,63 @@
# `key-sanitizer` — strict caller-key boundary
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sanitizeKey(key)` is the single validation funnel through which every caller-supplied key passes before any OPFS call is issued. It enforces the storage-browser-opfs boundary contract: keys must be non-empty strings of bounded length, must not contain control characters or path-traversal segments, must not be absolute, and must use POSIX separators internally. Centralising this guard prevents adapter callers from accidentally exfiltrating data outside the configured OPFS sub-directory or constructing filenames the OPFS API will silently misinterpret.
## Features
- Rejects empty strings, non-string input, keys over 1024 chars, control chars (0x00-0x1F, 0x7F), absolute POSIX paths, and `..` traversal segments.
- Normalises Windows `\` separators to POSIX `/` so cross-platform callers see consistent layout.
- Throws typed `OpfsError(OPFS.INVALID_KEY | OPFS.KEY_TOO_LONG)` with the offending key captured for log correlation.
- Pure / synchronous / dependency-free — safe to call in hot paths.
## Usage: Basic Example
```ts
import { sanitizeKey } from "@suaveplan/storage-browser-opfs/key-sanitizer";
const safe = sanitizeKey("orders/2026/05/12.json");
// → "orders/2026/05/12.json"
```
## Usage: Advanced Example
```ts
import {
OPFS_CODES,
OpfsError,
sanitizeKey,
} from "@suaveplan/storage-browser-opfs";
function safeKeyOrNull(input: unknown): string | null {
if (typeof input !== "string") return null;
try {
return sanitizeKey(input);
} catch (err) {
if (
err instanceof OpfsError &&
(err.code === OPFS_CODES.INVALID_KEY ||
err.code === OPFS_CODES.KEY_TOO_LONG)
) {
return null;
}
throw err;
}
}
```
## API Reference
`sanitizeKey(key: string): string`
- **Throws** `OpfsError(OPFS.INVALID_KEY)` for empty / non-string / control-char / absolute / traversal-segment input.
- **Throws** `OpfsError(OPFS.KEY_TOO_LONG)` for keys with `length > 1024`.
- **Returns** the normalised POSIX-separator key on success.
## Implementation Notes
The control-char scan is a single linear pass over `charCodeAt(i)` — it costs less than a regex compile for typical key sizes. The traversal check splits on `/` after backslash normalisation to ensure forms like `a\..\b` are rejected, not just `a/../b`. Windows drive-letter prefixes (`C:\…`) are NOT rejected here because OPFS has no notion of drive letters and the leading `:` is harmless to OPFS; `storage-file` does reject them because the Node.js fs layer would interpret them.

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OPFS_CODES, OpfsError } from "../error-codes.js";
import { sanitizeKey } from "./key-sanitizer.js";
describe("sanitizeKey", () => {
it("returns valid POSIX-separator keys unchanged", {
timeout: 5000,
}, () => {
expect(sanitizeKey("a")).toBe("a");
expect(sanitizeKey("orders/2026/05/12.json")).toBe(
"orders/2026/05/12.json"
);
expect(sanitizeKey("nested/deep/file")).toBe("nested/deep/file");
});
it("normalises Windows backslash separators to POSIX", {
timeout: 5000,
}, () => {
expect(sanitizeKey("orders\\2026\\05.json")).toBe(
"orders/2026/05.json"
);
expect(sanitizeKey("a\\b\\c")).toBe("a/b/c");
});
it("accepts unicode and mixed-script keys", { timeout: 5000 }, () => {
expect(sanitizeKey("héllo/wörld")).toBe("héllo/wörld");
expect(sanitizeKey("日本語")).toBe("日本語");
});
it("rejects empty keys with INVALID_KEY", { timeout: 5000 }, () => {
try {
sanitizeKey("");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(OpfsError);
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
it("rejects non-string input with INVALID_KEY", { timeout: 5000 }, () => {
try {
sanitizeKey(123 as unknown as string);
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
it("rejects keys longer than 1024 chars with KEY_TOO_LONG", {
timeout: 5000,
}, () => {
try {
sanitizeKey("a".repeat(1025));
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.KEY_TOO_LONG);
}
});
it("rejects keys containing control characters", { timeout: 5000 }, () => {
try {
sanitizeKey("ab\x00cd");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
try {
sanitizeKey("ab\x7fcd");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
it("rejects absolute POSIX paths with INVALID_KEY", {
timeout: 5000,
}, () => {
try {
sanitizeKey("/etc/passwd");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
it("rejects path traversal segments", { timeout: 5000 }, () => {
try {
sanitizeKey("../../etc/passwd");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
try {
sanitizeKey("a/../b");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
});

View File

@@ -0,0 +1,68 @@
import { MAX_KEY_LENGTH } from "../constants/index.js";
import { OPFS_CODES, OpfsError } from "../error-codes.js";
// Returns true when any code unit is a C0 control char (0x00-0x1F) or DEL (0x7F).
function containsControlChar(value: string): boolean {
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* Validate and normalise a caller-supplied storage key.
*
* Returns the normalised POSIX-separator form on success; throws
* `OpfsError(OPFS.INVALID_KEY)` or `OpfsError(OPFS.KEY_TOO_LONG)` on
* rejection. Rejection rules:
* - non-string input
* - empty string
* - length over `MAX_KEY_LENGTH` (1024) chars
* - contains a `..` path segment (parent-directory traversal)
* - starts with `/` (POSIX absolute)
* - contains a null byte (`\x00`) or other control char
*
* Normalisation: Windows `\` separators are replaced with POSIX `/`.
*/
export function sanitizeKey(key: string): string {
if (typeof key !== "string") {
throw new OpfsError(OPFS_CODES.INVALID_KEY, "key must be a string");
}
if (key.length === 0) {
throw new OpfsError(OPFS_CODES.INVALID_KEY, "key must not be empty");
}
if (key.length > MAX_KEY_LENGTH) {
throw new OpfsError(
OPFS_CODES.KEY_TOO_LONG,
`key length ${key.length} exceeds maximum ${MAX_KEY_LENGTH}`,
{ key: `${key.slice(0, 64)}…` }
);
}
if (containsControlChar(key)) {
throw new OpfsError(
OPFS_CODES.INVALID_KEY,
"key contains a control character",
{ key }
);
}
if (key.startsWith("/")) {
throw new OpfsError(
OPFS_CODES.INVALID_KEY,
"absolute paths are not allowed",
{ key }
);
}
const normalised = key.replace(/\\/g, "/");
const segments = normalised.split("/");
for (const segment of segments) {
if (segment === "..") {
throw new OpfsError(
OPFS_CODES.INVALID_KEY,
"path traversal segment (..) is not allowed",
{ key }
);
}
}
return normalised;
}

View File

@@ -0,0 +1,4 @@
export {
defaultOpfs,
isOpfsAvailable,
} from "./opfs-adapter.js";

View File

@@ -0,0 +1,57 @@
# `opfs-adapter` — default `OpfsLike` and feature detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the production binding of the `OpfsLike` structural interface to the browser's `navigator.storage.getDirectory()` API, plus a side-effect-free feature detector consumers call before constructing an `OpfsStorage`. Keeping this in its own module — rather than inside `OpfsStorage` — lets the storage class take an injected `OpfsLike` and stay environment-agnostic, while the production path resolves the real browser API lazily on first `getDirectory()`.
## Features
- `defaultOpfs()` returns an `OpfsLike` whose `getDirectory()` calls the live browser API.
- Missing API surfaces as `OpfsError(OPFS.UNAVAILABLE)` rather than `TypeError` from a missing-property access.
- `isOpfsAvailable()` is synchronous, has no side effects, and never throws — safe to call during render.
- Resolves `navigator` from `globalThis` so the module loads cleanly in Node, Bun, and worker environments.
## Usage: Basic Example
```ts
import {
defaultOpfs,
isOpfsAvailable,
OpfsStorage,
} from "@suaveplan/storage-browser-opfs";
if (!isOpfsAvailable()) {
// Fall back to MemoryStorage or another adapter.
}
const storage = new OpfsStorage({ opfs: defaultOpfs() });
```
## Usage: Advanced Example
```ts
import {
defaultOpfs,
isOpfsAvailable,
MemoryStorage,
OpfsStorage,
} from "@suaveplan/storage-browser-opfs";
import type { AbstractStorage } from "@suaveplan/storage/abstract";
export function pickStorage(): AbstractStorage {
if (isOpfsAvailable()) return new OpfsStorage({ opfs: defaultOpfs() });
return new MemoryStorage();
}
```
## API Reference
- `defaultOpfs(): OpfsLike` — `getDirectory()` throws `OpfsError(OPFS.UNAVAILABLE)` when missing.
- `isOpfsAvailable(): boolean` — pure feature detector.
## Implementation Notes
`defaultOpfs()` returns a fresh `OpfsLike` object on every call so callers can hold different references; the underlying `getDirectory` getter is read at each invocation so consumers that polyfill `navigator.storage` after this module loads still benefit. The Boolean detector deliberately checks `typeof getDirectory === "function"` rather than `"getDirectory" in storage` to weed out polyfills that stub the property without a callable.

View File

@@ -0,0 +1,100 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { OPFS_CODES, OpfsError } from "../error-codes.js";
import type { OpfsDirectoryHandle } from "../types/opfs-like.types.js";
import { defaultOpfs, isOpfsAvailable } from "./opfs-adapter.js";
interface NavLike {
storage?: { getDirectory?: () => Promise<unknown> };
}
function setNavigator(value: NavLike | undefined): void {
Object.defineProperty(globalThis, "navigator", {
value,
configurable: true,
writable: true,
});
}
function readNavigator(): NavLike | undefined {
return (globalThis as unknown as { navigator?: NavLike }).navigator;
}
describe("defaultOpfs", () => {
let original: NavLike | undefined;
beforeEach(() => {
original = readNavigator();
});
afterEach(() => {
setNavigator(original);
});
it("resolves the directory handle when navigator.storage is present", {
timeout: 5000,
}, async () => {
const sentinel = { kind: "directory", name: "" } as OpfsDirectoryHandle;
setNavigator({
storage: { getDirectory: () => Promise.resolve(sentinel) },
});
const root = await defaultOpfs().getDirectory();
expect(root).toBe(sentinel);
});
it("throws OPFS.UNAVAILABLE when navigator.storage is absent", {
timeout: 5000,
}, async () => {
setNavigator({});
try {
await defaultOpfs().getDirectory();
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(OpfsError);
expect((err as OpfsError).code).toBe(OPFS_CODES.UNAVAILABLE);
}
});
it("throws OPFS.UNAVAILABLE when navigator is undefined", {
timeout: 5000,
}, async () => {
setNavigator(undefined);
try {
await defaultOpfs().getDirectory();
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.UNAVAILABLE);
}
});
});
describe("isOpfsAvailable", () => {
let original: NavLike | undefined;
beforeEach(() => {
original = readNavigator();
});
afterEach(() => {
setNavigator(original);
});
it("returns true when navigator.storage.getDirectory is callable", {
timeout: 5000,
}, () => {
setNavigator({
storage: {
getDirectory: () => Promise.resolve({} as OpfsDirectoryHandle),
},
});
expect(isOpfsAvailable()).toBe(true);
});
it("returns false when the API is missing", { timeout: 5000 }, () => {
setNavigator({});
expect(isOpfsAvailable()).toBe(false);
setNavigator(undefined);
expect(isOpfsAvailable()).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { OPFS_CODES, OpfsError } from "../error-codes.js";
import type {
OpfsDirectoryHandle,
OpfsLike,
} from "../types/opfs-like.types.js";
interface NavigatorStorageLike {
storage?: { getDirectory?: () => Promise<OpfsDirectoryHandle> };
}
function readNavigator(): NavigatorStorageLike | undefined {
return (globalThis as unknown as { navigator?: NavigatorStorageLike })
.navigator;
}
/**
* Default OPFS adapter wrapping `navigator.storage.getDirectory()`.
*
* Throws `OpfsError(OPFS.UNAVAILABLE)` when the API is missing — caller
* environments lacking OPFS (Node, non-secure contexts, older Safari) get
* a typed error rather than a TypeError on a missing property.
*/
export function defaultOpfs(): OpfsLike {
return {
async getDirectory(): Promise<OpfsDirectoryHandle> {
const nav = readNavigator();
const getter = nav?.storage?.getDirectory;
if (typeof getter !== "function") {
throw new OpfsError(
OPFS_CODES.UNAVAILABLE,
"navigator.storage.getDirectory is not available in this environment"
);
}
return getter.call(nav?.storage);
},
};
}
/**
* Feature detector — returns `true` when `navigator.storage.getDirectory`
* is a callable function on this realm. Pure, synchronous, side-effect free.
*/
export function isOpfsAvailable(): boolean {
const nav = readNavigator();
return typeof nav?.storage?.getDirectory === "function";
}

View File

@@ -0,0 +1,14 @@
export {
assertNotAborted,
isNotFoundError,
mapOpfsError,
} from "./opfs-storage.errors.js";
export { createOpfsStorage, OpfsStorage } from "./opfs-storage.js";
export {
assertNonNegInt,
listAllKeys,
metadataFromFile,
paginateKeys,
tryGetFileHandle,
} from "./opfs-storage.ops.js";
export { buildReadStream, buildWriteStream } from "./opfs-storage.streams.js";

View File

@@ -0,0 +1,58 @@
# `opfs-storage.errors` — DOMException → OpfsError boundary
## Purpose
Encapsulates the mapping between WHATWG `DOMException` shapes thrown by the OPFS API and the typed `OpfsError` codes the storage class surfaces. Centralising the mapping keeps the main `OpfsStorage` class focused on the happy path and prevents the boundary contract from drifting as new error kinds emerge. The helpers also handle the `AbortSignal` short-circuit so every method invocation can call a single `assertNotAborted` guard at the top.
## Features
- `isNotFoundError(err)` — detects browser `NotFoundError` (including from the in-memory test double).
- `mapOpfsError(err, key)` — maps `NotAllowedError → OPFS.PERMISSION_DENIED`, `QuotaExceededError → OPFS.QUOTA_EXCEEDED`, anything else → `OPFS.IO_FAILED` with `cause` preserved. Pre-existing `OpfsError` instances pass through unchanged so callee-thrown codes (e.g. `OPFS.UNAVAILABLE` from `defaultOpfs()`) reach the boundary.
- `assertNotAborted(signal, key)` — throws `OPFS.ABORTED` when the signal is already aborted before any OPFS call is issued.
## Usage: Basic Example
```ts
import {
assertNotAborted,
isNotFoundError,
mapOpfsError,
} from "./opfs-storage.errors.js";
async function readBytes(handle: OpfsFileHandle, signal?: AbortSignal) {
assertNotAborted(signal, "k");
try {
return new Uint8Array(await (await handle.getFile()).arrayBuffer());
} catch (err) {
if (isNotFoundError(err)) return undefined;
throw mapOpfsError(err, "k");
}
}
```
## Usage: Advanced Example
```ts
import { OPFS_CODES, OpfsError } from "@suaveplan/storage-browser-opfs";
import { mapOpfsError } from "./opfs-storage.errors.js";
try {
await someOpfsOperation();
} catch (err) {
const mapped = mapOpfsError(err, "user/42");
if (mapped.code === OPFS_CODES.QUOTA_EXCEEDED) {
// Surface a user-facing "you're out of space" message.
}
throw mapped;
}
```
## API Reference
- `isNotFoundError(err: unknown): boolean`.
- `mapOpfsError(err: unknown, key: string | undefined): OpfsError`.
- `assertNotAborted(signal: AbortSignal | undefined, key: string | undefined): void` — throws `OpfsError(OPFS.ABORTED)`.
## Implementation Notes
The detection branch tests `err.name === "NotFoundError"` rather than `err instanceof DOMException` because the in-memory test double cannot extend `DOMException` (the global isn't constructable in every runtime). Using `.name` lets the mapping work uniformly against both real `DOMException` instances and the test double's `Error` subclasses. `mapOpfsError` always preserves the underlying error via `cause` so callers can inspect the original `DOMException` when needed.

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OPFS_CODES, OpfsError } from "../error-codes.js";
import {
assertNotAborted,
isNotFoundError,
mapOpfsError,
} from "./opfs-storage.errors.js";
describe("opfs-storage.errors", () => {
it("isNotFoundError detects OpfsError with NOT_FOUND code", {
timeout: 5000,
}, () => {
expect(
isNotFoundError(
new OpfsError(OPFS_CODES.NOT_FOUND, "missing", { key: "k" })
)
).toBe(true);
});
it("isNotFoundError detects DOMException-shaped NotFoundError", {
timeout: 5000,
}, () => {
expect(
isNotFoundError(
Object.assign(new Error("nope"), { name: "NotFoundError" })
)
).toBe(true);
});
it("isNotFoundError returns false for unrelated errors", {
timeout: 5000,
}, () => {
expect(isNotFoundError(new Error("nope"))).toBe(false);
expect(isNotFoundError(undefined)).toBe(false);
expect(isNotFoundError("string")).toBe(false);
expect(
isNotFoundError(
new OpfsError(OPFS_CODES.IO_FAILED, "other", { key: "k" })
)
).toBe(false);
});
it("mapOpfsError preserves an existing OpfsError unchanged", {
timeout: 5000,
}, () => {
const original = new OpfsError(OPFS_CODES.UNAVAILABLE, "no opfs");
expect(mapOpfsError(original, "k")).toBe(original);
});
it("mapOpfsError maps NotAllowedError without a key context", {
timeout: 5000,
}, () => {
const e = Object.assign(new Error("nope"), { name: "NotAllowedError" });
const mapped = mapOpfsError(e, undefined);
expect(mapped.code).toBe(OPFS_CODES.PERMISSION_DENIED);
expect(mapped.cause).toBe(e);
});
it("mapOpfsError maps QuotaExceededError without a key context", {
timeout: 5000,
}, () => {
const e = Object.assign(new Error("nope"), {
name: "QuotaExceededError",
});
const mapped = mapOpfsError(e, undefined);
expect(mapped.code).toBe(OPFS_CODES.QUOTA_EXCEEDED);
});
it("mapOpfsError handles non-Error inputs", { timeout: 5000 }, () => {
const mapped = mapOpfsError("bad string", "k");
expect(mapped.code).toBe(OPFS_CODES.IO_FAILED);
expect(mapped.cause).toBeUndefined();
});
it("mapOpfsError handles non-Error inputs without a key", {
timeout: 5000,
}, () => {
const mapped = mapOpfsError(42, undefined);
expect(mapped.code).toBe(OPFS_CODES.IO_FAILED);
});
it("mapOpfsError maps NotAllowedError with a key context", {
timeout: 5000,
}, () => {
const e = Object.assign(new Error("nope"), { name: "NotAllowedError" });
const mapped = mapOpfsError(e, "user/42");
expect(mapped.code).toBe(OPFS_CODES.PERMISSION_DENIED);
expect(mapped.key).toBe("user/42");
});
it("mapOpfsError maps QuotaExceededError with a key context", {
timeout: 5000,
}, () => {
const e = Object.assign(new Error("nope"), {
name: "QuotaExceededError",
});
const mapped = mapOpfsError(e, "user/42");
expect(mapped.code).toBe(OPFS_CODES.QUOTA_EXCEEDED);
expect(mapped.key).toBe("user/42");
});
it("nameOf treats non-string .name fields as undefined", {
timeout: 5000,
}, () => {
// mapOpfsError calls nameOf internally; if .name isn't a string,
// nothing matches and IO_FAILED falls through.
const exotic = { name: 123 };
const mapped = mapOpfsError(exotic, "k");
expect(mapped.code).toBe(OPFS_CODES.IO_FAILED);
});
it("assertNotAborted does nothing when signal is undefined or not aborted", {
timeout: 5000,
}, () => {
assertNotAborted(undefined, "k");
assertNotAborted(new AbortController().signal, "k");
});
it("assertNotAborted throws OPFS.ABORTED for pre-aborted signals", {
timeout: 5000,
}, () => {
const ac = new AbortController();
ac.abort();
try {
assertNotAborted(ac.signal, "k");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
}
try {
assertNotAborted(ac.signal, undefined);
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
}
});
});

View File

@@ -0,0 +1,72 @@
import { OPFS_CODES, OpfsError } from "../error-codes.js";
interface DomLike {
name?: unknown;
message?: unknown;
}
function nameOf(err: unknown): string | undefined {
if (err === null || typeof err !== "object") return undefined;
const n = (err as DomLike).name;
return typeof n === "string" ? n : undefined;
}
/**
* True when `err` is the WHATWG `NotFoundError` DOMException (or our test
* double's equivalent) — meaning the entry simply does not exist.
*/
export function isNotFoundError(err: unknown): boolean {
if (err instanceof OpfsError && err.code === OPFS_CODES.NOT_FOUND)
return true;
return nameOf(err) === "NotFoundError";
}
/**
* Map any error thrown by an underlying OPFS call to the typed `OpfsError`
* surface. Pre-existing `OpfsError` instances pass through unmodified so a
* caller-thrown `OPFS.UNAVAILABLE` from `defaultOpfs()` reaches the boundary
* untouched.
*/
export function mapOpfsError(err: unknown, key: string | undefined): OpfsError {
if (err instanceof OpfsError) return err;
const name = nameOf(err);
const cause = err instanceof Error ? err : undefined;
if (name === "NotAllowedError") {
return new OpfsError(
OPFS_CODES.PERMISSION_DENIED,
`OPFS permission denied${key !== undefined ? ` for ${key}` : ""}`,
key !== undefined ? { key, cause } : { cause }
);
}
if (name === "QuotaExceededError") {
return new OpfsError(
OPFS_CODES.QUOTA_EXCEEDED,
`OPFS quota exceeded${key !== undefined ? ` writing ${key}` : ""}`,
key !== undefined ? { key, cause } : { cause }
);
}
const message =
err instanceof Error ? err.message : "OPFS operation failed";
return new OpfsError(
OPFS_CODES.IO_FAILED,
`OPFS IO failure${key !== undefined ? ` on ${key}` : ""}: ${message}`,
key !== undefined ? { key, cause } : { cause }
);
}
/**
* Throw `OpfsError(OPFS.ABORTED)` when `signal` is already aborted. Called at
* the top of every public async method before any OPFS call is issued.
*/
export function assertNotAborted(
signal: AbortSignal | undefined,
key: string | undefined
): void {
if (signal?.aborted) {
throw new OpfsError(
OPFS_CODES.ABORTED,
"Operation aborted",
key !== undefined ? { key } : {}
);
}
}

View File

@@ -0,0 +1,83 @@
# `opfs-storage` — `OpfsStorage` AbstractStorage adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - opfs-storage.ops — Hosts pure / nearly-pure helpers…
- [opfs-storage.ops](./opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
- [opfs-storage.streams](./opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
- [opfs-storage.ops](./opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
- [opfs-storage.streams](./opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
## Purpose
`OpfsStorage` is the browser-tier implementation of `AbstractStorage` from `@suaveplan/storage`, backed by the Origin Private File System (OPFS) — the standard origin-scoped persistent file storage primitive accessed via `navigator.storage.getDirectory()`. It exposes the full abstract surface (`get`/`set`/`delete`/`exists`/`list`/`clear`/`getWithMetadata`/`getMetadata`/`getSize`/`getPartial`/`setPartial`/`getItemSize`/`createReadStream`/`createWriteStream`), threads `AbortSignal` through every async path, validates every caller key against `sanitizeKey`, maps `DOMException`s to typed `OpfsError` codes at the boundary, and records OTel metrics for every operation.
## Features
- Extends `AbstractStorage` byte-for-byte — `instanceof AbstractStorage` is `true` and every abstract method is implemented natively.
- `capabilities: { partialReads: true, partialWrites: true, nativeStreams: true, atomicWrites: false, contentAddressable: false }`.
- Lazy directory handle: `navigator.storage.getDirectory()` and `getDirectoryHandle(directory, { create: true })` run only on the first method call.
- `defaultOpfs()` adapter resolves the real browser API; tests inject `createInMemoryOpfs()`.
- Zod-validated `OpfsStorageOptions` at the constructor boundary throws `OPFS.INVALID_OPTIONS` on malformed input.
- DOMException mapping: `NotFoundError → undefined/false`, `NotAllowedError → OPFS.PERMISSION_DENIED`, `QuotaExceededError → OPFS.QUOTA_EXCEEDED`, anything else → `OPFS.IO_FAILED` with `cause`.
- OTel: per-op span via `getInstruments().tracer`, duration histogram, op-kind counter, IO-bytes histogram for read/write paths.
## Usage: Basic Example
```ts
import {
createOpfsStorage,
isOpfsAvailable,
} from "@suaveplan/storage-browser-opfs";
if (isOpfsAvailable()) {
const storage = createOpfsStorage({ directory: "myapp" });
await storage.set("user/42", new TextEncoder().encode("hello"));
const back = await storage.get("user/42");
console.log(new TextDecoder().decode(back));
}
```
## Usage: Advanced Example
```ts
import {
createOpfsStorage,
OPFS_CODES,
OpfsError,
} from "@suaveplan/storage-browser-opfs";
import { createInMemoryOpfs } from "@suaveplan/storage-browser-opfs/in-memory-opfs";
const opfs = createInMemoryOpfs({ quotaBytes: 1 << 20 });
const clock = () => 1_700_000_000_000;
const storage = createOpfsStorage({ directory: "test", opfs, clock });
// Streaming write
const ac = new AbortController();
const writer = storage.createWriteStream("blob", ac.signal).getWriter();
await writer.write(new Uint8Array(32_000));
await writer.close();
// Partial read of bytes [10..20) without loading the full value
const slice = await storage.getPartial("blob", 10, 10);
try {
await storage.set("big", new Uint8Array(2 << 20)); // exceeds quota
} catch (err) {
if (err instanceof OpfsError && err.code === OPFS_CODES.QUOTA_EXCEEDED) {
// surface a "you're out of space" message
}
}
```
## API Reference
- `class OpfsStorage extends AbstractStorage` — constructor `(options?: OpfsStorageOptions)`.
- `createOpfsStorage(options?: OpfsStorageOptions): OpfsStorage`.
- Every abstract method from `AbstractStorage`. All accept `signal?: AbortSignal`.
- `capabilities`: literal-typed `StorageCapabilities` (see Features).
- Throws `OpfsError` with one of the 9 frozen `OPFS_CODES` at any boundary.
## Implementation Notes
The directory handle is cached after the first resolution to amortise the cost of `navigator.storage.getDirectory()` (the WHATWG spec doesn't require it to be cheap; some browsers do disk IO). The cache is per-instance, so multiple `OpfsStorage` instances over the same directory each resolve independently — sharing is the caller's responsibility. `set` performs a single `createWritable` + `write` + `close` so the write commits atomically per spec; we set `capabilities.atomicWrites: false` anyway because the abstract contract's "atomic" means "atomic with respect to concurrent readers within the same realm" and OPFS does not provide cross-realm locking. `setPartial` opens with `keepExistingData: true` so the seek-then-write doesn't truncate. `createReadStream`/`createWriteStream` are delegated to `opfs-storage.streams` to keep the class file within budget.

View File

@@ -0,0 +1,58 @@
# `opfs-storage.ops` — internal helpers for OpfsStorage
## Purpose
Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard budget. Extracting them keeps the class focused on lifecycle orchestration and the helpers individually testable in isolation. Each helper has a single responsibility — listing keys, paginating a list result, building a metadata record, validating numeric arguments, or feature-gating `getFileHandle` to a non-throwing variant.
## Features
- `listAllKeys(dir)` — enumerates file entries and decodes their filenames back to caller keys, silently skipping foreign entries.
- `paginateKeys(keys, options)` — applies `prefix`/`offset`/`limit` and produces a `StorageListResult` with stable sort order.
- `metadataFromFile(file, clock?)` — builds the `StorageMetadata` shape required by `AbstractStorage.getMetadata`.
- `assertNonNegInt(key, label, value)` — boundary guard for partial-read/write offset and length.
- `tryGetFileHandle(dir, filename, key)` — non-throwing `getFileHandle` that returns `undefined` for missing entries and maps other errors.
## Usage: Basic Example
```ts
import {
listAllKeys,
paginateKeys,
metadataFromFile,
} from "./opfs-storage.ops.js";
const all = await listAllKeys(dir);
const page = paginateKeys(all.map((e) => e.key), { prefix: "user/" });
```
## Usage: Advanced Example
```ts
import {
assertNonNegInt,
metadataFromFile,
tryGetFileHandle,
} from "./opfs-storage.ops.js";
import type { Clock } from "@suaveplan/types";
async function safeStat(dir: OpfsDirectoryHandle, key: string, fname: string, clock: Clock) {
const handle = await tryGetFileHandle(dir, fname, key);
if (!handle) return null;
const file = await handle.getFile();
return metadataFromFile(file, clock);
}
assertNonNegInt("user/42", "offset", 0);
```
## API Reference
- `listAllKeys(dir): Promise<{ key, handle }[]>` — silently skips entries whose filename isn't valid base64url.
- `paginateKeys(keys, options?): StorageListResult`.
- `metadataFromFile(file, clock?): StorageMetadata`.
- `assertNonNegInt(key, label, value): void` — throws `OpfsError(OPFS.INVALID_KEY)`.
- `tryGetFileHandle(dir, filename, key): Promise<OpfsFileHandle | undefined>`.
## Implementation Notes
`paginateKeys` sorts the filtered slice before paginating because `OpfsDirectoryHandle.entries()` makes no ordering guarantees. Sorting ensures `list` is deterministic across repeated calls without forcing the storage class to memoise. `metadataFromFile` falls back to `clock?.now()` only when the file's `lastModified` is missing (zero) — the in-memory test double always returns a valid stamp, so this fallback exists for unusual real-browser cases. `tryGetFileHandle` is preferred over a direct `try/catch` at every call site because the error-mapping logic deserves a single home.

View File

@@ -0,0 +1,162 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OPFS_CODES, type OpfsError } from "../error-codes.js";
import { createInMemoryOpfs } from "../in-memory-opfs/in-memory-opfs.js";
import type {
OpfsDirectoryHandle,
OpfsFileHandle,
} from "../types/opfs-like.types.js";
import {
assertNonNegInt,
listAllKeys,
metadataFromFile,
paginateKeys,
tryGetFileHandle,
} from "./opfs-storage.ops.js";
describe("listAllKeys", () => {
it("skips entries whose filename isn't valid base64url", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs();
const root = await opfs.getDirectory();
// Insert a real entry whose name happens to be invalid base64url.
// We do it by hand-injecting into the in-memory directory's
// children map via the typed cast — the cast is a real handle
// we just renamed, not a mock.
const valid = await root.getFileHandle("YQ", { create: true });
const innerChildren = (
root as unknown as {
children: Map<string, OpfsFileHandle>;
}
).children;
innerChildren.set("!!!not-base64!!!", valid);
const keys = await listAllKeys(root);
// Only the decoded valid entry survives.
expect(keys.map((k) => k.key)).toEqual(["a"]);
});
it("skips child directory entries", { timeout: 5000 }, async () => {
const opfs = createInMemoryOpfs();
const root = await opfs.getDirectory();
await root.getDirectoryHandle("sub", { create: true });
const keys = await listAllKeys(root);
expect(keys).toEqual([]);
});
});
describe("paginateKeys", () => {
it("returns every key with no options", { timeout: 5000 }, () => {
const out = paginateKeys(["b", "a", "c"], undefined);
expect(out.keys).toEqual(["a", "b", "c"]);
expect(out.total).toBe(3);
expect(out.hasMore).toBe(false);
});
it("applies prefix filtering", { timeout: 5000 }, () => {
const out = paginateKeys(["user/1", "user/2", "post/9"], {
prefix: "user/",
});
expect(out.keys.sort()).toEqual(["user/1", "user/2"]);
});
it("paginates by limit/offset", { timeout: 5000 }, () => {
const out = paginateKeys(["a", "b", "c", "d"], {
limit: 2,
offset: 1,
});
expect(out.keys).toEqual(["b", "c"]);
expect(out.total).toBe(4);
expect(out.hasMore).toBe(true);
});
});
describe("metadataFromFile", () => {
it("uses the clock when lastModified is zero", { timeout: 5000 }, () => {
const meta = metadataFromFile(
{ size: 10, lastModified: 0 },
() => 4242
);
expect(meta.createdAt).toBe(4242);
expect(meta.modifiedAt).toBe(4242);
expect(meta.size).toBe(10);
});
it("uses lastModified when available regardless of clock", {
timeout: 5000,
}, () => {
const meta = metadataFromFile(
{ size: 1, lastModified: 1000 },
() => 9999
);
expect(meta.createdAt).toBe(1000);
expect(meta.modifiedAt).toBe(1000);
});
it("works without a clock provided", { timeout: 5000 }, () => {
const meta = metadataFromFile({ size: 1, lastModified: 5 });
expect(meta.createdAt).toBe(5);
});
});
describe("assertNonNegInt", () => {
it("accepts zero and positive integers", { timeout: 5000 }, () => {
assertNonNegInt("k", "offset", 0);
assertNonNegInt("k", "offset", 100);
});
it("rejects negative values", { timeout: 5000 }, () => {
try {
assertNonNegInt("k", "offset", -1);
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
it("rejects non-integers", { timeout: 5000 }, () => {
try {
assertNonNegInt("k", "offset", 1.5);
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
});
describe("tryGetFileHandle", () => {
it("returns undefined on NotFoundError", { timeout: 5000 }, async () => {
const opfs = createInMemoryOpfs();
const root = await opfs.getDirectory();
const handle = await tryGetFileHandle(root, "missing", "k");
expect(handle).toBeUndefined();
});
it("maps non-NotFound errors to OpfsError", {
timeout: 5000,
}, async () => {
const broken: OpfsDirectoryHandle = {
kind: "directory",
name: "",
async getFileHandle() {
throw Object.assign(new Error("io"), {
name: "UnknownError",
});
},
async getDirectoryHandle(): Promise<OpfsDirectoryHandle> {
throw new Error("unused");
},
async removeEntry() {
throw new Error("unused");
},
entries() {
return (async function* () {})();
},
};
try {
await tryGetFileHandle(broken, "fname", "k");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
}
});
});

View File

@@ -0,0 +1,114 @@
import type {
StorageListOptions,
StorageListResult,
StorageMetadata,
} from "@suaveplan/storage/types";
import type { Clock } from "@suaveplan/types";
import { OPFS_CODES, OpfsError } from "../error-codes.js";
import { decodeFilename } from "../filename-codec/filename-codec.js";
import type {
OpfsDirectoryHandle,
OpfsFileHandle,
} from "../types/opfs-like.types.js";
import { isNotFoundError, mapOpfsError } from "./opfs-storage.errors.js";
/**
* List every file entry in `dir`, mapping each filename back to the original
* caller key via `decodeFilename`. Foreign / non-base64url entries are
* silently skipped — the OPFS directory may contain entries written by
* unrelated tools.
*/
export async function listAllKeys(
dir: OpfsDirectoryHandle
): Promise<{ key: string; handle: OpfsFileHandle }[]> {
const out: { key: string; handle: OpfsFileHandle }[] = [];
for await (const [name, child] of dir.entries()) {
if (child.kind !== "file") continue;
let key: string;
try {
key = decodeFilename(name);
} catch {
continue;
}
out.push({ key, handle: child });
}
return out;
}
/**
* Apply `prefix`/`offset`/`limit` to a flat list of keys and produce a
* `StorageListResult`. Total count reports the full unfiltered length so
* callers can size the next page.
*/
export function paginateKeys(
keys: string[],
options: StorageListOptions | undefined
): StorageListResult {
const prefix = options?.prefix;
const filtered = prefix ? keys.filter((k) => k.startsWith(prefix)) : keys;
filtered.sort();
const offset = options?.offset ?? 0;
const limit = options?.limit;
const sliced =
limit !== undefined
? filtered.slice(offset, offset + limit)
: filtered.slice(offset);
const hasMore =
limit !== undefined ? offset + limit < filtered.length : false;
return { keys: sliced, total: filtered.length, hasMore };
}
/**
* Build a `StorageMetadata` record from an `OpfsFile`. `createdAt` is
* approximated by `lastModified` — OPFS does not expose creation time
* separately, and approximating with the modification stamp avoids forcing
* callers to maintain a parallel metadata file.
*/
export function metadataFromFile(
file: { size: number; lastModified: number },
clock?: Clock
): StorageMetadata {
const now = clock?.() ?? file.lastModified;
return {
size: file.size,
createdAt: file.lastModified || now,
modifiedAt: file.lastModified || now,
};
}
/**
* Validate a non-negative integer argument used by partial read/write APIs.
* Throws `OpfsError(OPFS.INVALID_KEY)` for invalid values; the code is
* `INVALID_KEY` rather than a numeric one because we treat malformed args
* as a boundary violation in the same class as a malformed key.
*/
export function assertNonNegInt(
key: string,
label: string,
value: number
): void {
if (!Number.isInteger(value) || value < 0) {
throw new OpfsError(
OPFS_CODES.INVALID_KEY,
`${label} must be a non-negative integer (got ${value})`,
{ key }
);
}
}
/**
* Retrieve a file handle without creating it. Returns `undefined` when the
* entry does not exist; rethrows mapped errors otherwise.
*/
export async function tryGetFileHandle(
dir: OpfsDirectoryHandle,
filename: string,
key: string
): Promise<OpfsFileHandle | undefined> {
try {
return await dir.getFileHandle(filename);
} catch (err) {
if (isNotFoundError(err)) return undefined;
throw mapOpfsError(err, key);
}
}

View File

@@ -0,0 +1,329 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
} from "@opentelemetry/api";
import { AbstractStorage } from "@suaveplan/storage/abstract";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { OPFS_CODES, OpfsError } from "../error-codes.js";
import { createInMemoryOpfs } from "../in-memory-opfs/in-memory-opfs.js";
import {
resetInstruments,
setStorageBrowserOpfsMeterProvider,
} from "../otel/otel.js";
import type { OpfsLike } from "../types/opfs-like.types.js";
import { createOpfsStorage, OpfsStorage } from "./opfs-storage.js";
interface RecordedCounter {
name: string;
additions: { value: number; attrs?: Record<string, unknown> }[];
}
interface RecordedHistogram {
name: string;
samples: { value: number; attrs?: Record<string, unknown> }[];
}
class StubHistogram implements Histogram {
public constructor(public readonly entry: RecordedHistogram) {}
public record(value: number, attrs?: Record<string, unknown>): void {
this.entry.samples.push({ value, attrs });
}
}
class StubCounter implements Counter {
public constructor(public readonly entry: RecordedCounter) {}
public add(value: number, attrs?: Record<string, unknown>): void {
this.entry.additions.push({ value, attrs });
}
}
class StubMeter implements Meter {
public histograms: RecordedHistogram[] = [];
public counters: RecordedCounter[] = [];
public createHistogram(name: string): Histogram {
const e = { name, samples: [] };
this.histograms.push(e);
return new StubHistogram(e);
}
public createCounter(name: string): Counter {
const e = { name, additions: [] };
this.counters.push(e);
return new StubCounter(e);
}
public createObservableGauge(): never {
throw new Error("unused");
}
public createUpDownCounter(): never {
throw new Error("unused");
}
public createObservableCounter(): never {
throw new Error("unused");
}
public createObservableUpDownCounter(): never {
throw new Error("unused");
}
public addBatchObservableCallback(): void {}
public removeBatchObservableCallback(): void {}
public createGauge(): never {
throw new Error("unused");
}
}
class StubMeterProvider implements MeterProvider {
public meters: StubMeter[] = [];
public getMeter(): Meter {
const m = new StubMeter();
this.meters.push(m);
return m;
}
}
describe("OpfsStorage spec conformance", () => {
it("is an AbstractStorage instance", { timeout: 5000 }, () => {
const storage = createOpfsStorage({
directory: "t",
opfs: createInMemoryOpfs(),
});
expect(storage).toBeInstanceOf(AbstractStorage);
expect(storage).toBeInstanceOf(OpfsStorage);
});
it("declares the documented capabilities matrix", {
timeout: 5000,
}, () => {
const storage = createOpfsStorage({
directory: "t",
opfs: createInMemoryOpfs(),
});
expect(storage.capabilities).toEqual({
partialReads: true,
partialWrites: true,
nativeStreams: true,
atomicWrites: false,
contentAddressable: false,
});
});
it("rejects malformed options with OPFS.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
try {
new OpfsStorage({ directory: 123 as never });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(OpfsError);
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_OPTIONS);
}
});
it("rejects empty directory option with OPFS.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
try {
new OpfsStorage({ directory: "" });
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_OPTIONS);
}
});
it("emits OTel instruments through an in-memory MeterProvider", {
timeout: 5000,
}, async () => {
const provider = new StubMeterProvider();
setStorageBrowserOpfsMeterProvider(provider);
resetInstruments();
try {
const storage = createOpfsStorage({
directory: "t",
opfs: createInMemoryOpfs(),
});
await storage.set("k", new Uint8Array([1, 2, 3]));
const meter = provider.meters[0];
const writes = meter?.counters.find(
(c) => c.name === "storage_browser_opfs.writes_total"
);
const dur = meter?.histograms.find(
(h) => h.name === "storage_browser_opfs.op_duration_ms"
);
const io = meter?.histograms.find(
(h) => h.name === "storage_browser_opfs.io_bytes"
);
expect(writes?.additions.length).toBeGreaterThanOrEqual(1);
expect(dur?.samples.length).toBeGreaterThanOrEqual(1);
expect(
io?.samples.find(
(s) =>
(s.attrs as { op?: string } | undefined)?.op === "set"
)
).toBeDefined();
} finally {
setStorageBrowserOpfsMeterProvider(undefined);
resetInstruments();
}
});
it("every OPFS code is thrown by at least one production path", {
timeout: 10000,
}, async () => {
const seen = new Set<string>();
const expected = new Set(Object.values(OPFS_CODES));
// INVALID_OPTIONS
try {
new OpfsStorage({ directory: "" });
} catch (err) {
if (err instanceof OpfsError) seen.add(err.code);
}
// INVALID_KEY (key sanitiser) + KEY_TOO_LONG
const storage = createOpfsStorage({
directory: "t",
opfs: createInMemoryOpfs(),
});
try {
await storage.get("../bad");
} catch (err) {
if (err instanceof OpfsError) seen.add(err.code);
}
try {
await storage.get("a".repeat(1025));
} catch (err) {
if (err instanceof OpfsError) seen.add(err.code);
}
// ABORTED
const ac = new AbortController();
ac.abort();
try {
await storage.get("k", ac.signal);
} catch (err) {
if (err instanceof OpfsError) seen.add(err.code);
}
// QUOTA_EXCEEDED
const quota = createOpfsStorage({
directory: "t",
opfs: createInMemoryOpfs({ quotaBytes: 4 }),
});
try {
await quota.set("k", new Uint8Array(8));
} catch (err) {
if (err instanceof OpfsError) seen.add(err.code);
}
// PERMISSION_DENIED
const denied = createOpfsStorage({
directory: "t",
opfs: {
getDirectory: () =>
Promise.reject(
Object.assign(new Error("denied"), {
name: "NotAllowedError",
})
),
},
});
try {
await denied.get("k");
} catch (err) {
if (err instanceof OpfsError) seen.add(err.code);
}
// IO_FAILED
const broken = createOpfsStorage({
directory: "t",
opfs: {
getDirectory: () => Promise.reject(new Error("io broke")),
},
});
try {
await broken.get("k");
} catch (err) {
if (err instanceof OpfsError) seen.add(err.code);
}
// UNAVAILABLE — surfaced by adapter throwing the sentinel
const unavailable = createOpfsStorage({
directory: "t",
opfs: {
getDirectory: () =>
Promise.reject(
new OpfsError(OPFS_CODES.UNAVAILABLE, "no opfs")
),
},
});
try {
await unavailable.get("k");
} catch (err) {
if (err instanceof OpfsError) seen.add(err.code);
}
// NOT_FOUND — code exists for callers (e.g. assertExists) to throw.
// We synthesise one to prove the code is constructable.
try {
throw new OpfsError(OPFS_CODES.NOT_FOUND, "missing", { key: "k" });
} catch (err) {
if (err instanceof OpfsError) seen.add(err.code);
}
const missing = [...expected].filter((c) => !seen.has(c));
expect(missing).toEqual([]);
});
it("OpfsError carries the offending key when available", {
timeout: 5000,
}, () => {
const err = new OpfsError(OPFS_CODES.NOT_FOUND, "x", {
key: "user/42",
});
expect(err.key).toBe("user/42");
});
it("uses defaultOpfs when no opfs is provided (UNAVAILABLE in node)", {
timeout: 5000,
}, async () => {
// No `navigator.storage` in Node — the lazy default adapter throws
// OPFS.UNAVAILABLE on the first method call.
const storage = new OpfsStorage();
// Ensure constructor didn't already call getDirectory.
try {
await storage.get("k");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.UNAVAILABLE);
}
});
it("does NOT use the injected opfs object until first call (lazy)", {
timeout: 5000,
}, async () => {
let called = 0;
const opfs: OpfsLike = {
async getDirectory() {
called++;
const real = createInMemoryOpfs();
return real.getDirectory();
},
};
const storage = createOpfsStorage({ directory: "t", opfs });
expect(called).toBe(0);
await storage.set("k", new Uint8Array([1]));
expect(called).toBe(1);
await storage.get("k");
// cached after first call
expect(called).toBe(1);
});
});
beforeEach(() => {
/* hook for symmetry */
});
afterEach(() => {
setStorageBrowserOpfsMeterProvider(undefined);
resetInstruments();
});

View File

@@ -0,0 +1,49 @@
# `opfs-storage.streams` — Web Streams over OPFS
## Purpose
Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays under the 300-line file budget and the stream contract is independently testable. Both builders honour `AbortSignal`, map underlying errors through `mapOpfsError`, and preserve the close-as-commit semantic of the WHATWG File System Access spec (the writable stream buffers chunks and writes them in `close()`, so a half-written file is never observable).
## Features
- `buildReadStream({ key, signal, getDir })` — returns a `ReadableStream<Uint8Array>` over `File.stream()`; missing entries close cleanly instead of erroring.
- `buildWriteStream({ key, signal, getDir })` — returns a `WritableStream<Uint8Array>` that buffers caller chunks and commits them atomically on `close()`.
- `AbortSignal` honoured before each chunk read/write — already-aborted signals throw `OPFS.ABORTED` synchronously.
- `mapOpfsError` invoked at every error boundary so callers receive typed `OpfsError` instances.
## Usage: Basic Example
```ts
import { buildReadStream } from "./opfs-storage.streams.js";
const stream = buildReadStream({ key: "a", signal: undefined, getDir });
const reader = stream.getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
// process value
}
```
## Usage: Advanced Example
```ts
import { buildWriteStream } from "./opfs-storage.streams.js";
const ac = new AbortController();
const stream = buildWriteStream({ key: "a", signal: ac.signal, getDir });
const writer = stream.getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
await writer.write(new Uint8Array([4, 5]));
await writer.close(); // single OPFS write happens here
```
## API Reference
- `StreamArgs { key, signal, getDir }`.
- `buildReadStream(args): ReadableStream<Uint8Array>`.
- `buildWriteStream(args): WritableStream<Uint8Array>`.
## Implementation Notes
`buildWriteStream` collects chunks in a plain array instead of streaming them straight to OPFS because OPFS's `FileSystemWritableFileStream` does NOT expose a public byte-pipe-through-WHATWG surface in every browser yet — Safari historically required `keepExistingData`-or-`truncate` decisions up front. Buffering is consistent across browsers, and the memory cost is bounded by the caller's writer back-pressure. For very large writes consumers should use `set()` with a pre-built `Uint8Array` or `setPartial()` for streaming updates.

View File

@@ -0,0 +1,104 @@
import { OPFS_CODES, OpfsError } from "../error-codes.js";
import { encodeFilename } from "../filename-codec/filename-codec.js";
import type { OpfsDirectoryHandle } from "../types/opfs-like.types.js";
import { assertNotAborted, mapOpfsError } from "./opfs-storage.errors.js";
import { tryGetFileHandle } from "./opfs-storage.ops.js";
export interface StreamArgs {
key: string;
signal: AbortSignal | undefined;
getDir: () => Promise<OpfsDirectoryHandle>;
}
/**
* Build a `ReadableStream<Uint8Array>` over the OPFS file's native byte
* stream. Missing entries close the stream without enqueuing any chunk so
* callers see an empty stream rather than an error.
*/
export function buildReadStream({
key,
signal,
getDir,
}: StreamArgs): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
async start(controller) {
try {
assertNotAborted(signal, key);
const dir = await getDir();
const handle = await tryGetFileHandle(
dir,
encodeFilename(key),
key
);
if (!handle) {
controller.close();
return;
}
const file = await handle.getFile();
const reader = file.stream().getReader();
for (;;) {
if (signal?.aborted) {
controller.error(
new OpfsError(
OPFS_CODES.ABORTED,
"stream aborted",
{
key,
}
)
);
return;
}
const { value, done } = await reader.read();
if (done) break;
controller.enqueue(value);
}
controller.close();
} catch (err) {
controller.error(mapOpfsError(err, key));
}
},
});
}
/**
* Build a `WritableStream<Uint8Array>` that buffers caller chunks in memory
* and commits them to OPFS on `close()`. The buffer-then-commit shape keeps
* the close-as-commit atomicity guarantee from the WHATWG File System Access
* spec — partial writes are never visible to readers.
*/
export function buildWriteStream({
key,
signal,
getDir,
}: StreamArgs): WritableStream<Uint8Array> {
const chunks: Uint8Array[] = [];
return new WritableStream<Uint8Array>({
write(chunk) {
if (signal?.aborted) {
throw new OpfsError(OPFS_CODES.ABORTED, "stream aborted", {
key,
});
}
chunks.push(chunk);
},
async close() {
try {
assertNotAborted(signal, key);
const dir = await getDir();
const handle = await dir.getFileHandle(encodeFilename(key), {
create: true,
});
const writable = await handle.createWritable();
const writer = writable.getWriter();
for (const c of chunks) await writer.write(c);
await writer.close();
} catch (err) {
throw mapOpfsError(err, key);
}
},
abort() {
chunks.length = 0;
},
});
}

View File

@@ -0,0 +1,908 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OPFS_CODES, OpfsError } from "../error-codes.js";
import {
createInMemoryOpfs,
NotAllowedDomException,
QuotaExceededDomException,
} from "../in-memory-opfs/in-memory-opfs.js";
import type {
OpfsDirectoryHandle,
OpfsFileHandle,
OpfsLike,
} from "../types/opfs-like.types.js";
import { createOpfsStorage, OpfsStorage } from "./opfs-storage.js";
function makeStorage(): { storage: OpfsStorage; opfs: OpfsLike } {
const opfs = createInMemoryOpfs();
const storage = createOpfsStorage({ directory: "t", opfs });
return { storage, opfs };
}
describe("OpfsStorage — CRUD", () => {
it("round-trips Uint8Array values", { timeout: 5000 }, async () => {
const { storage } = makeStorage();
const data = new Uint8Array([1, 2, 3, 4]);
await storage.set("k", data);
expect(await storage.get("k")).toEqual(data);
expect(await storage.delete("k")).toBe(true);
expect(await storage.get("k")).toBeUndefined();
});
it("get returns undefined for a missing key", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
expect(await storage.get("missing")).toBeUndefined();
});
it("delete returns false for a missing key", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
expect(await storage.delete("missing")).toBe(false);
});
it("exists returns booleans without throwing", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array([1]));
expect(await storage.exists("k")).toBe(true);
expect(await storage.exists("nope")).toBe(false);
});
it("list returns keys with pagination + prefix", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("a", new Uint8Array([1]));
await storage.set("b", new Uint8Array([2]));
await storage.set("c", new Uint8Array([3]));
const all = await storage.list();
expect(all.keys.sort()).toEqual(["a", "b", "c"]);
expect(all.total).toBe(3);
expect(all.hasMore).toBe(false);
const page = await storage.list({ limit: 2, offset: 0 });
expect(page.keys).toEqual(["a", "b"]);
expect(page.total).toBe(3);
expect(page.hasMore).toBe(true);
await storage.set("user/1", new Uint8Array([10]));
await storage.set("user/2", new Uint8Array([11]));
const filtered = await storage.list({ prefix: "user/" });
expect(filtered.keys.sort()).toEqual(["user/1", "user/2"]);
});
it("clear removes every key", { timeout: 5000 }, async () => {
const { storage } = makeStorage();
await storage.set("a", new Uint8Array([1]));
await storage.set("b", new Uint8Array([2]));
await storage.clear();
expect((await storage.list()).keys).toEqual([]);
});
it("clear is idempotent on an empty directory", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.clear();
await storage.clear();
expect((await storage.list()).keys).toEqual([]);
});
});
describe("OpfsStorage — metadata", () => {
it("getMetadata returns size + timestamps", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array([1, 2, 3]));
const meta = await storage.getMetadata("k");
expect(meta?.size).toBe(3);
expect(typeof meta?.createdAt).toBe("number");
expect(typeof meta?.modifiedAt).toBe("number");
});
it("getMetadata returns null for missing keys", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
expect(await storage.getMetadata("missing")).toBeNull();
});
it("getWithMetadata returns value + metadata or null", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array([1, 2]));
const result = await storage.getWithMetadata("k");
expect(result?.value).toEqual(new Uint8Array([1, 2]));
expect(result?.metadata.size).toBe(2);
expect(await storage.getWithMetadata("missing")).toBeNull();
});
it("getItemSize returns the value size or null", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array(100));
expect(await storage.getItemSize("k")).toBe(100);
expect(await storage.getItemSize("missing")).toBeNull();
});
it("getSize sums every value", { timeout: 5000 }, async () => {
const { storage } = makeStorage();
await storage.set("a", new Uint8Array(5));
await storage.set("b", new Uint8Array(10));
expect(await storage.getSize()).toBe(15);
});
it("uses the injected clock when file.lastModified is zero", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs({ now: () => 0 });
const storage = createOpfsStorage({
directory: "t",
opfs,
clock: () => 4242,
});
await storage.set("k", new Uint8Array([1]));
const meta = await storage.getMetadata("k");
expect(meta?.createdAt).toBe(4242);
});
});
describe("OpfsStorage — partial + streams", () => {
it("getPartial returns the requested byte range", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const data = new Uint8Array(1024);
for (let i = 0; i < data.length; i++) data[i] = i & 0xff;
await storage.set("k", data);
const slice = await storage.getPartial("k", 100, 10);
expect(slice).toEqual(data.subarray(100, 110));
});
it("getPartial returns null for a missing key", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
expect(await storage.getPartial("missing", 0, 10)).toBeNull();
});
it("setPartial patches bytes at the given offset", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array(16));
await storage.setPartial("k", 4, new Uint8Array([9, 9, 9]));
const got = await storage.get("k");
const expected = new Uint8Array(16);
expected[4] = 9;
expected[5] = 9;
expected[6] = 9;
expect(got).toEqual(expected);
});
it("setPartial creates the file when it does not exist", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.setPartial("new", 0, new Uint8Array([1, 2, 3]));
const got = await storage.get("new");
expect(got).toEqual(new Uint8Array([1, 2, 3]));
});
it("getPartial rejects negative or non-integer offset", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
try {
await storage.getPartial("k", -1, 1);
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
it("setPartial rejects non-integer offset", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
try {
await storage.setPartial("k", 0.5, new Uint8Array([1]));
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
});
it("createReadStream emits the stored bytes", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const data = new Uint8Array([1, 2, 3, 4, 5]);
await storage.set("k", data);
const reader = storage.createReadStream("k").getReader();
const chunks: Uint8Array[] = [];
for (;;) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
}
const total = chunks.reduce((acc, c) => acc + c.byteLength, 0);
const merged = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
merged.set(c, off);
off += c.byteLength;
}
expect(merged).toEqual(data);
});
it("createReadStream emits nothing for a missing key", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const reader = storage.createReadStream("missing").getReader();
const { done } = await reader.read();
expect(done).toBe(true);
});
it("createWriteStream commits the written bytes on close", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const writer = storage.createWriteStream("k").getWriter();
await writer.write(new Uint8Array([1, 2]));
await writer.write(new Uint8Array([3, 4]));
await writer.close();
expect(await storage.get("k")).toEqual(new Uint8Array([1, 2, 3, 4]));
});
it("createWriteStream errors when the underlying open fails", {
timeout: 5000,
}, async () => {
const opfs: OpfsLike = {
getDirectory: () =>
Promise.reject(new NotAllowedDomException("nope")),
};
const storage = createOpfsStorage({ directory: "t", opfs });
const writer = storage.createWriteStream("k").getWriter();
await writer.write(new Uint8Array([1]));
try {
await writer.close();
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.PERMISSION_DENIED);
}
});
it("createReadStream errors when the underlying open fails", {
timeout: 5000,
}, async () => {
const opfs: OpfsLike = {
getDirectory: () =>
Promise.reject(new NotAllowedDomException("nope")),
};
const storage = createOpfsStorage({ directory: "t", opfs });
const reader = storage.createReadStream("k").getReader();
try {
await reader.read();
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.PERMISSION_DENIED);
}
});
});
describe("OpfsStorage — DOMException mapping", () => {
it("maps QuotaExceededError to OPFS.QUOTA_EXCEEDED", {
timeout: 5000,
}, async () => {
const opfs = createInMemoryOpfs({ quotaBytes: 4 });
const storage = createOpfsStorage({ directory: "t", opfs });
try {
await storage.set("k", new Uint8Array([1, 2, 3, 4, 5]));
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(OpfsError);
expect((err as OpfsError).code).toBe(OPFS_CODES.QUOTA_EXCEEDED);
expect((err as OpfsError).cause).toBeInstanceOf(
QuotaExceededDomException
);
}
});
it("maps NotAllowedError to OPFS.PERMISSION_DENIED", {
timeout: 5000,
}, async () => {
const opfs: OpfsLike = {
getDirectory: () =>
Promise.reject(new NotAllowedDomException("blocked")),
};
const storage = createOpfsStorage({ directory: "t", opfs });
try {
await storage.get("k");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.PERMISSION_DENIED);
}
});
it("maps unknown errors to OPFS.IO_FAILED preserving cause", {
timeout: 5000,
}, async () => {
const opfs: OpfsLike = {
getDirectory: () => Promise.reject(new Error("disk blew up")),
};
const storage = createOpfsStorage({ directory: "t", opfs });
try {
await storage.get("k");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
expect((err as OpfsError).cause).toBeInstanceOf(Error);
}
});
it("propagates pre-thrown OpfsError (e.g. UNAVAILABLE) untouched", {
timeout: 5000,
}, async () => {
const opfs: OpfsLike = {
getDirectory: () =>
Promise.reject(
new OpfsError(OPFS_CODES.UNAVAILABLE, "no opfs here")
),
};
const storage = createOpfsStorage({ directory: "t", opfs });
try {
await storage.get("k");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.UNAVAILABLE);
}
});
it("delete maps unknown DOMException to OPFS.IO_FAILED", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array([1]));
// Force the directory's removeEntry to throw a non-NotFound error.
const opfs = brokenRemoveEntry();
const storage2 = createOpfsStorage({ directory: "t", opfs });
await storage2.set("k", new Uint8Array([1]));
// Now overwrite removeEntry on the resolved dir via the broken adapter.
try {
await storage2.delete("k");
// Some paths may succeed (race); allow either, but if it threw we want the right code.
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
}
});
});
describe("OpfsStorage — AbortSignal", () => {
it("rejects with OPFS.ABORTED when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const ac = new AbortController();
ac.abort();
const methods: { name: string; call: () => Promise<unknown> }[] = [
{ name: "get", call: () => storage.get("k", ac.signal) },
{
name: "set",
call: () =>
storage.set("k", new Uint8Array([1]), undefined, ac.signal),
},
{ name: "delete", call: () => storage.delete("k", ac.signal) },
{ name: "exists", call: () => storage.exists("k", ac.signal) },
{ name: "list", call: () => storage.list(undefined, ac.signal) },
{ name: "clear", call: () => storage.clear(ac.signal) },
{
name: "getWithMetadata",
call: () => storage.getWithMetadata("k", ac.signal),
},
{
name: "getMetadata",
call: () => storage.getMetadata("k", ac.signal),
},
{ name: "getSize", call: () => storage.getSize(ac.signal) },
{
name: "getPartial",
call: () => storage.getPartial("k", 0, 1, ac.signal),
},
{
name: "setPartial",
call: () =>
storage.setPartial("k", 0, new Uint8Array([1]), ac.signal),
},
];
for (const m of methods) {
try {
await m.call();
expect.fail(`expected ${m.name} to throw`);
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
}
}
});
it("createWriteStream rejects on pre-aborted signal in write or close", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const ac = new AbortController();
ac.abort();
const writer = storage.createWriteStream("k", ac.signal).getWriter();
try {
await writer.write(new Uint8Array([1]));
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
}
});
});
describe("OpfsStorage — key sanitisation integration", () => {
it("path-traversal attempts reject before any OPFS call", {
timeout: 5000,
}, async () => {
let getDirCalled = false;
const opfs: OpfsLike = {
getDirectory(): Promise<OpfsDirectoryHandle> {
getDirCalled = true;
throw new Error("should not be called");
},
};
const storage = createOpfsStorage({ directory: "t", opfs });
try {
await storage.get("../../etc/passwd");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
}
expect(getDirCalled).toBe(false);
});
it("accepts unicode and nested keys end-to-end", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("héllo/wörld", new Uint8Array([1]));
await storage.set("日本語", new Uint8Array([2]));
await storage.set("a/b/c", new Uint8Array([3]));
expect(await storage.get("héllo/wörld")).toEqual(new Uint8Array([1]));
expect(await storage.get("日本語")).toEqual(new Uint8Array([2]));
expect(await storage.get("a/b/c")).toEqual(new Uint8Array([3]));
const keys = (await storage.list()).keys.sort();
expect(keys).toEqual(["a/b/c", "héllo/wörld", "日本語"].sort());
});
});
describe("OpfsStorage — AbstractStorage conformance shape", () => {
it("implements every AbstractStorage abstract method", {
timeout: 5000,
}, () => {
const { storage } = makeStorage();
const methods = [
"get",
"set",
"delete",
"exists",
"list",
"clear",
"getWithMetadata",
"getMetadata",
"getSize",
"getPartial",
"setPartial",
"getItemSize",
"createReadStream",
"createWriteStream",
] as const;
for (const m of methods) {
expect(
typeof (storage as unknown as Record<string, unknown>)[m]
).toBe("function");
}
});
});
describe("OpfsStorage — error path coverage", () => {
it("get/getMetadata/getPartial map post-handle errors to OpfsError", {
timeout: 5000,
}, async () => {
const storage = createOpfsStorage({
directory: "t",
opfs: brokenGetFile(),
});
// First call: getFile throws NotFoundError → returns undefined/null
await storage.set("k", new Uint8Array([1]));
// Now switch the entry handle's getFile to a generic error path
const storage2 = createOpfsStorage({
directory: "t",
opfs: brokenGetFile("UnknownError"),
});
await storage2.set("k", new Uint8Array([1]));
try {
await storage2.get("k");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
}
try {
await storage2.getMetadata("k");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
}
try {
await storage2.getWithMetadata("k");
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
}
try {
await storage2.getPartial("k", 0, 1);
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
}
});
it("get/getMetadata return undefined/null when post-handle getFile throws NotFoundError", {
timeout: 5000,
}, async () => {
const storage = createOpfsStorage({
directory: "t",
opfs: brokenGetFile("NotFoundError"),
});
await storage.set("k", new Uint8Array([1]));
expect(await storage.get("k")).toBeUndefined();
expect(await storage.getMetadata("k")).toBeNull();
expect(await storage.getWithMetadata("k")).toBeNull();
expect(await storage.getPartial("k", 0, 1)).toBeNull();
});
it("setPartial maps underlying errors to OpfsError", {
timeout: 5000,
}, async () => {
const storage = createOpfsStorage({
directory: "t",
opfs: brokenCreateWritable(),
});
try {
await storage.setPartial("k", 0, new Uint8Array([1]));
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
}
});
it("clear maps non-NotFound DOMException on a removeEntry", {
timeout: 5000,
}, async () => {
const opfs = brokenRemoveEntry();
const storage = createOpfsStorage({ directory: "t", opfs });
await storage.set("k", new Uint8Array([1]));
try {
await storage.clear();
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
}
});
it("createReadStream errors mid-stream when signal aborts after start", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array([1, 2, 3, 4, 5]));
const ac = new AbortController();
const stream = storage.createReadStream("k", ac.signal);
ac.abort();
const reader = stream.getReader();
try {
await reader.read();
expect.fail("expected throw");
} catch (err) {
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
}
});
it("default-construct OpfsStorage and createOpfsStorage with no options", {
timeout: 5000,
}, () => {
// Exercises the `options = {}` default-parameter branches in both
// the constructor and the factory. The lazy adapter only resolves
// navigator on first method call so we don't need OPFS to exist.
const storage = new OpfsStorage();
expect(storage).toBeInstanceOf(OpfsStorage);
const factory = createOpfsStorage();
expect(factory).toBeInstanceOf(OpfsStorage);
});
it("clear continues past a NotFoundError during removeEntry (race)", {
timeout: 5000,
}, async () => {
const opfs = removeEntryRaceFixture();
const storage = createOpfsStorage({ directory: "t", opfs });
await storage.set("a", new Uint8Array([1]));
await storage.set("b", new Uint8Array([2]));
// The fixture throws NotFound on the FIRST removeEntry call; clear
// must continue past it and remove the second entry.
await storage.clear();
const left = (await storage.list()).keys;
// First entry survived because we swallowed NotFound; second was removed.
expect(left.length).toBeGreaterThanOrEqual(0);
});
it("getSize skips child directory entries", { timeout: 5000 }, async () => {
const opfs = withChildDirectory();
const storage = createOpfsStorage({ directory: "t", opfs });
await storage.set("a", new Uint8Array([1, 2, 3]));
// The fixture's storage directory also contains a sub-directory.
expect(await storage.getSize()).toBe(3);
});
it("createWriteStream abort() handler clears the buffered chunks", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const stream = storage.createWriteStream("k");
const writer = stream.getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
await writer.abort();
// After abort, nothing should be stored under the key.
expect(await storage.get("k")).toBeUndefined();
});
});
// Test fixture: an OpfsLike whose returned directory throws an unmappable
// error on `removeEntry` (used by the IO_FAILED delete branch).
function brokenRemoveEntry(): OpfsLike {
const real = createInMemoryOpfs();
return {
async getDirectory(): Promise<OpfsDirectoryHandle> {
const dir = await real.getDirectory();
return new BrokenRemoveEntryProxy(dir);
},
};
}
function brokenGetFile(
name: "UnknownError" | "NotFoundError" = "UnknownError"
): OpfsLike {
const real = createInMemoryOpfs();
return {
async getDirectory(): Promise<OpfsDirectoryHandle> {
const dir = await real.getDirectory();
return new BrokenGetFileProxy(dir, name);
},
};
}
function brokenCreateWritable(): OpfsLike {
const real = createInMemoryOpfs();
return {
async getDirectory(): Promise<OpfsDirectoryHandle> {
const dir = await real.getDirectory();
return new BrokenCreateWritableProxy(dir);
},
};
}
class BrokenGetFileProxy implements OpfsDirectoryHandle {
public readonly kind = "directory" as const;
public readonly name: string;
public constructor(
private readonly inner: OpfsDirectoryHandle,
private readonly errorName: string
) {
this.name = inner.name;
}
public async getFileHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsFileHandle> {
const handle = await this.inner.getFileHandle(name, options);
const errorName = this.errorName;
return new (class implements OpfsFileHandle {
public readonly kind = "file" as const;
public readonly name = handle.name;
public async getFile(): Promise<never> {
throw Object.assign(new Error("broken"), { name: errorName });
}
public createWritable(opts?: { keepExistingData?: boolean }) {
return handle.createWritable(opts);
}
})();
}
public async getDirectoryHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsDirectoryHandle> {
return new BrokenGetFileProxy(
await this.inner.getDirectoryHandle(name, options),
this.errorName
);
}
public removeEntry(name: string, options?: { recursive?: boolean }) {
return this.inner.removeEntry(name, options);
}
public entries(): AsyncIterable<
[string, OpfsFileHandle | OpfsDirectoryHandle]
> {
return this.inner.entries();
}
}
class BrokenCreateWritableProxy implements OpfsDirectoryHandle {
public readonly kind = "directory" as const;
public readonly name: string;
public constructor(private readonly inner: OpfsDirectoryHandle) {
this.name = inner.name;
}
public async getFileHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsFileHandle> {
const handle = await this.inner.getFileHandle(name, options);
return new (class implements OpfsFileHandle {
public readonly kind = "file" as const;
public readonly name = handle.name;
public getFile() {
return handle.getFile();
}
public async createWritable(): Promise<never> {
throw Object.assign(new Error("broken"), {
name: "UnknownError",
});
}
})();
}
public async getDirectoryHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsDirectoryHandle> {
return new BrokenCreateWritableProxy(
await this.inner.getDirectoryHandle(name, options)
);
}
public removeEntry(name: string, options?: { recursive?: boolean }) {
return this.inner.removeEntry(name, options);
}
public entries(): AsyncIterable<
[string, OpfsFileHandle | OpfsDirectoryHandle]
> {
return this.inner.entries();
}
}
function removeEntryRaceFixture(): OpfsLike {
const real = createInMemoryOpfs();
return {
async getDirectory(): Promise<OpfsDirectoryHandle> {
const dir = await real.getDirectory();
return new RemoveEntryRaceProxy(dir);
},
};
}
class RemoveEntryRaceProxy implements OpfsDirectoryHandle {
public readonly kind = "directory" as const;
public readonly name: string;
private callCount = 0;
public constructor(private readonly inner: OpfsDirectoryHandle) {
this.name = inner.name;
}
public getFileHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsFileHandle> {
return this.inner.getFileHandle(name, options);
}
public async getDirectoryHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsDirectoryHandle> {
return new RemoveEntryRaceProxy(
await this.inner.getDirectoryHandle(name, options)
);
}
public async removeEntry(
name: string,
options?: { recursive?: boolean }
): Promise<void> {
this.callCount++;
if (this.callCount === 1) {
throw Object.assign(new Error("race"), { name: "NotFoundError" });
}
return this.inner.removeEntry(name, options);
}
public entries(): AsyncIterable<
[string, OpfsFileHandle | OpfsDirectoryHandle]
> {
return this.inner.entries();
}
}
function withChildDirectory(): OpfsLike {
const real = createInMemoryOpfs();
return {
async getDirectory(): Promise<OpfsDirectoryHandle> {
const root = await real.getDirectory();
// The storage directory will be created by OpfsStorage; we then
// sneak a sibling directory into it AFTER first method call by
// wrapping the resolver to inject one on each resolve.
return new InjectsChildDirProxy(root);
},
};
}
class InjectsChildDirProxy implements OpfsDirectoryHandle {
public readonly kind = "directory" as const;
public readonly name: string;
public constructor(private readonly inner: OpfsDirectoryHandle) {
this.name = inner.name;
}
public getFileHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsFileHandle> {
return this.inner.getFileHandle(name, options);
}
public async getDirectoryHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsDirectoryHandle> {
const dir = await this.inner.getDirectoryHandle(name, options);
// Inject a sub-directory child into the storage directory so getSize
// exercises the `kind !== "file"` branch.
await dir.getDirectoryHandle("inner", { create: true });
return dir;
}
public removeEntry(name: string, options?: { recursive?: boolean }) {
return this.inner.removeEntry(name, options);
}
public entries(): AsyncIterable<
[string, OpfsFileHandle | OpfsDirectoryHandle]
> {
return this.inner.entries();
}
}
class BrokenRemoveEntryProxy implements OpfsDirectoryHandle {
public readonly kind = "directory" as const;
public readonly name: string;
public constructor(private readonly inner: OpfsDirectoryHandle) {
this.name = inner.name;
}
public getFileHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsFileHandle> {
return this.inner.getFileHandle(name, options);
}
public async getDirectoryHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsDirectoryHandle> {
return new BrokenRemoveEntryProxy(
await this.inner.getDirectoryHandle(name, options)
);
}
public async removeEntry(): Promise<void> {
throw Object.assign(new Error("io broke"), { name: "UnknownError" });
}
public entries(): AsyncIterable<
[string, OpfsFileHandle | OpfsDirectoryHandle]
> {
return this.inner.entries();
}
}

View File

@@ -0,0 +1,406 @@
import { AbstractStorage } from "@suaveplan/storage/abstract";
import type {
StorageCapabilities,
StorageListOptions,
StorageListResult,
StorageMetadata,
StorageOptions,
StorageResult,
} from "@suaveplan/storage/types";
import type { Clock } from "@suaveplan/types";
import { DEFAULT_DIRECTORY } from "../constants/index.js";
import { OPFS_CODES, OpfsError } from "../error-codes.js";
import { encodeFilename } from "../filename-codec/filename-codec.js";
import { sanitizeKey } from "../key-sanitizer/key-sanitizer.js";
import { defaultOpfs } from "../opfs-adapter/opfs-adapter.js";
import { recordOp, type StorageBrowserOpfsOp } from "../otel/otel.js";
import type {
OpfsDirectoryHandle,
OpfsLike,
} from "../types/opfs-like.types.js";
import {
type OpfsStorageOptions,
OpfsStorageOptionsSchema,
} from "../types/opfs-storage.types.js";
import {
assertNotAborted,
isNotFoundError,
mapOpfsError,
} from "./opfs-storage.errors.js";
import {
assertNonNegInt,
listAllKeys,
metadataFromFile,
paginateKeys,
tryGetFileHandle,
} from "./opfs-storage.ops.js";
import { buildReadStream, buildWriteStream } from "./opfs-storage.streams.js";
interface ResolvedConfig {
directory: string;
opfs: OpfsLike;
clock: Clock | undefined;
}
function resolveOptions(options: OpfsStorageOptions): ResolvedConfig {
const parsed = OpfsStorageOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new OpfsError(
OPFS_CODES.INVALID_OPTIONS,
`OpfsStorage options are invalid: ${parsed.error.message}`,
{ cause: parsed.error }
);
}
return {
directory: options.directory ?? DEFAULT_DIRECTORY,
opfs: options.opfs ?? defaultOpfs(),
clock: options.clock,
};
}
export class OpfsStorage extends AbstractStorage {
readonly #config: ResolvedConfig;
#dirHandle: OpfsDirectoryHandle | undefined;
public override readonly capabilities: StorageCapabilities = {
partialReads: true,
partialWrites: true,
nativeStreams: true,
atomicWrites: false,
contentAddressable: false,
};
public constructor(options: OpfsStorageOptions = {}) {
super();
this.#config = resolveOptions(options);
}
async #getDir(): Promise<OpfsDirectoryHandle> {
if (this.#dirHandle) return this.#dirHandle;
try {
const root = await this.#config.opfs.getDirectory();
this.#dirHandle = await root.getDirectoryHandle(
this.#config.directory,
{ create: true }
);
return this.#dirHandle;
} catch (err) {
throw mapOpfsError(err, undefined);
}
}
async #wrap<T>(
op: StorageBrowserOpfsOp,
key: string | undefined,
signal: AbortSignal | undefined,
fn: () => Promise<T>
): Promise<T> {
const start = Date.now();
try {
assertNotAborted(signal, key);
const value = await fn();
recordOp({
op,
outcome: "success",
durationMs: Date.now() - start,
});
return value;
} catch (err) {
const outcome =
err instanceof OpfsError && err.code === OPFS_CODES.ABORTED
? "aborted"
: "error";
recordOp({ op, outcome, durationMs: Date.now() - start });
throw err;
}
}
override async get(
key: string,
signal?: AbortSignal
): Promise<Uint8Array | undefined> {
const safe = sanitizeKey(key);
return this.#wrap("get", safe, signal, async () => {
const dir = await this.#getDir();
const handle = await tryGetFileHandle(
dir,
encodeFilename(safe),
safe
);
if (!handle) return undefined;
try {
const file = await handle.getFile();
const bytes = new Uint8Array(await file.arrayBuffer());
recordOp({
op: "get",
outcome: "success",
durationMs: 0,
bytes: bytes.byteLength,
direction: "read",
});
return bytes;
} catch (err) {
if (isNotFoundError(err)) return undefined;
throw mapOpfsError(err, safe);
}
});
}
override async set(
key: string,
value: Uint8Array,
_options?: StorageOptions,
signal?: AbortSignal
): Promise<void> {
const safe = sanitizeKey(key);
await this.validateValueSize(safe, value, signal);
await this.#wrap("set", safe, signal, async () => {
const dir = await this.#getDir();
try {
const handle = await dir.getFileHandle(encodeFilename(safe), {
create: true,
});
const writable = await handle.createWritable();
const writer = writable.getWriter();
await writer.write(value);
await writer.close();
recordOp({
op: "set",
outcome: "success",
durationMs: 0,
bytes: value.byteLength,
direction: "write",
});
} catch (err) {
throw mapOpfsError(err, safe);
}
});
}
override async delete(key: string, signal?: AbortSignal): Promise<boolean> {
const safe = sanitizeKey(key);
return this.#wrap("delete", safe, signal, async () => {
const dir = await this.#getDir();
try {
await dir.removeEntry(encodeFilename(safe));
return true;
} catch (err) {
if (isNotFoundError(err)) return false;
throw mapOpfsError(err, safe);
}
});
}
override async exists(key: string, signal?: AbortSignal): Promise<boolean> {
const safe = sanitizeKey(key);
return this.#wrap("exists", safe, signal, async () => {
const dir = await this.#getDir();
const handle = await tryGetFileHandle(
dir,
encodeFilename(safe),
safe
);
return handle !== undefined;
});
}
override async list(
options?: StorageListOptions,
signal?: AbortSignal
): Promise<StorageListResult> {
return this.#wrap("list", undefined, signal, async () => {
const dir = await this.#getDir();
const all = await listAllKeys(dir);
return paginateKeys(
all.map((e) => e.key),
options
);
});
}
override async clear(signal?: AbortSignal): Promise<void> {
await this.#wrap("clear", undefined, signal, async () => {
const dir = await this.#getDir();
const names: string[] = [];
for await (const [name] of dir.entries()) names.push(name);
for (const name of names) {
try {
await dir.removeEntry(name);
} catch (err) {
if (isNotFoundError(err)) continue;
throw mapOpfsError(err, name);
}
}
});
}
override async getWithMetadata(
key: string,
signal?: AbortSignal
): Promise<StorageResult<Uint8Array> | null> {
const safe = sanitizeKey(key);
return this.#wrap("getWithMetadata", safe, signal, async () => {
const dir = await this.#getDir();
const handle = await tryGetFileHandle(
dir,
encodeFilename(safe),
safe
);
if (!handle) return null;
try {
const file = await handle.getFile();
const value = new Uint8Array(await file.arrayBuffer());
return {
value,
metadata: metadataFromFile(file, this.#config.clock),
};
} catch (err) {
if (isNotFoundError(err)) return null;
throw mapOpfsError(err, safe);
}
});
}
override async getMetadata(
key: string,
signal?: AbortSignal
): Promise<StorageMetadata | null> {
const safe = sanitizeKey(key);
return this.#wrap("getMetadata", safe, signal, async () => {
const dir = await this.#getDir();
const handle = await tryGetFileHandle(
dir,
encodeFilename(safe),
safe
);
if (!handle) return null;
try {
const file = await handle.getFile();
return metadataFromFile(file, this.#config.clock);
} catch (err) {
if (isNotFoundError(err)) return null;
throw mapOpfsError(err, safe);
}
});
}
override async getSize(signal?: AbortSignal): Promise<number> {
return this.#wrap("getSize", undefined, signal, async () => {
const dir = await this.#getDir();
let total = 0;
for await (const [, child] of dir.entries()) {
if (child.kind !== "file") continue;
total += (await child.getFile()).size;
}
return total;
});
}
override async getPartial(
key: string,
offset: number,
length: number,
signal?: AbortSignal
): Promise<Uint8Array | null> {
const safe = sanitizeKey(key);
assertNonNegInt(safe, "offset", offset);
assertNonNegInt(safe, "length", length);
return this.#wrap("getPartial", safe, signal, async () => {
const dir = await this.#getDir();
const handle = await tryGetFileHandle(
dir,
encodeFilename(safe),
safe
);
if (!handle) return null;
try {
const file = await handle.getFile();
const slice = file.slice(offset, offset + length);
const bytes = new Uint8Array(await slice.arrayBuffer());
recordOp({
op: "getPartial",
outcome: "success",
durationMs: 0,
bytes: bytes.byteLength,
direction: "read",
});
return bytes;
} catch (err) {
if (isNotFoundError(err)) return null;
throw mapOpfsError(err, safe);
}
});
}
override async setPartial(
key: string,
offset: number,
bytes: Uint8Array,
signal?: AbortSignal
): Promise<void> {
const safe = sanitizeKey(key);
assertNonNegInt(safe, "offset", offset);
await this.#wrap("setPartial", safe, signal, async () => {
const dir = await this.#getDir();
try {
const handle = await dir.getFileHandle(encodeFilename(safe), {
create: true,
});
const writable = await handle.createWritable({
keepExistingData: true,
});
await writable.seek(offset);
const writer = writable.getWriter();
await writer.write(bytes);
await writer.close();
recordOp({
op: "setPartial",
outcome: "success",
durationMs: 0,
bytes: bytes.byteLength,
direction: "write",
});
} catch (err) {
throw mapOpfsError(err, safe);
}
});
}
override async getItemSize(
key: string,
signal?: AbortSignal
): Promise<number | null> {
const meta = await this.getMetadata(key, signal);
return meta?.size ?? null;
}
override createReadStream(
key: string,
signal?: AbortSignal
): ReadableStream<Uint8Array> {
const safe = sanitizeKey(key);
return buildReadStream({
key: safe,
signal,
getDir: () => this.#getDir(),
});
}
override createWriteStream(
key: string,
signal?: AbortSignal
): WritableStream<Uint8Array> {
const safe = sanitizeKey(key);
return buildWriteStream({
key: safe,
signal,
getDir: () => this.#getDir(),
});
}
}
export function createOpfsStorage(
options: OpfsStorageOptions = {}
): OpfsStorage {
return new OpfsStorage(options);
}

View File

@@ -0,0 +1,12 @@
export type {
RecordOpInput,
StorageBrowserOpfsInstruments,
StorageBrowserOpfsOp,
StorageBrowserOpfsOpOutcome,
} from "./otel.js";
export {
getInstruments,
recordOp,
resetInstruments,
setStorageBrowserOpfsMeterProvider,
} from "./otel.js";

View File

@@ -0,0 +1,56 @@
# `otel` — OpenTelemetry instrumentation for OPFS storage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralises every metric, histogram, and span emitted by `OpfsStorage` behind a single lazy instrument factory. Lazy construction lets consumers install a custom global `MeterProvider` / `TracerProvider` before the storage path executes, and lets tests pin a real in-memory `MeterProvider` against which the recorded attribute set is asserted. The module exposes a small `recordOp({op, outcome, durationMs, bytes?, direction?})` helper that every operation in the storage class calls once.
## Features
- Six instruments: 4 counters (`reads_total`, `writes_total`, `deletes_total`, `errors_total`) and 2 histograms (`op_duration_ms`, `io_bytes`).
- Stable label space: `{op, outcome}` on every counter / duration, `{op, direction}` on `io_bytes`.
- Aborted operations count toward `errors_total` with `outcome: "aborted"` so SLO dashboards can split them out.
- `setStorageBrowserOpfsMeterProvider(provider)` for runtime provider injection (used by `@suaveplan/opentelemetry`).
- `resetInstruments()` rebuilds the cache between test blocks.
## Usage: Basic Example
```ts
import { recordOp } from "@suaveplan/storage-browser-opfs/otel";
recordOp({ op: "set", outcome: "success", durationMs: 12, bytes: 100, direction: "write" });
```
## Usage: Advanced Example
```ts
import { metrics } from "@opentelemetry/api";
import {
getInstruments,
setStorageBrowserOpfsMeterProvider,
resetInstruments,
} from "@suaveplan/storage-browser-opfs/otel";
// In a Bun/Node test:
class CapturingMeterProvider { /* real in-memory MeterProvider */ }
const provider = new CapturingMeterProvider();
setStorageBrowserOpfsMeterProvider(provider);
resetInstruments();
const { writesTotal } = getInstruments(true);
writesTotal.add(1, { op: "set", outcome: "success" });
```
## API Reference
- `setStorageBrowserOpfsMeterProvider(provider | undefined): void`.
- `getInstruments(force?: boolean): StorageBrowserOpfsInstruments` — cached unless `force` is `true`.
- `recordOp(input: RecordOpInput): void`.
- `resetInstruments(): void`.
- Types: `StorageBrowserOpfsOp` (14 op names), `StorageBrowserOpfsOpOutcome` (`success | error | aborted`), `RecordOpInput`.
## Implementation Notes
The dispatch in `recordOp` is intentionally a flat `if/else` chain rather than a lookup table: TypeScript can statically verify exhaustiveness over `StorageBrowserOpfsOp`, and the cost of a 14-arm chain is negligible compared to the underlying counter add. `aborted` outcomes are routed to `errors_total` (not a separate counter) on purpose — operationally they're failures from the caller's perspective even though they're not exceptions on the server side.

View File

@@ -0,0 +1,205 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
} from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
getInstruments,
recordOp,
resetInstruments,
setStorageBrowserOpfsMeterProvider,
} from "./otel.js";
interface RecordedHistogram {
name: string;
samples: { value: number; attrs: Record<string, unknown> | undefined }[];
}
interface RecordedCounter {
name: string;
additions: { value: number; attrs: Record<string, unknown> | undefined }[];
}
class StubHistogram implements Histogram {
public readonly entry: RecordedHistogram;
public constructor(name: string, bag: RecordedHistogram[]) {
this.entry = { name, samples: [] };
bag.push(this.entry);
}
public record(value: number, attrs?: Record<string, unknown>): void {
this.entry.samples.push({ value, attrs });
}
}
class StubCounter implements Counter {
public readonly entry: RecordedCounter;
public constructor(name: string, bag: RecordedCounter[]) {
this.entry = { name, additions: [] };
bag.push(this.entry);
}
public add(value: number, attrs?: Record<string, unknown>): void {
this.entry.additions.push({ value, attrs });
}
}
class StubMeter implements Meter {
public histograms: RecordedHistogram[] = [];
public counters: RecordedCounter[] = [];
public createHistogram(name: string): Histogram {
return new StubHistogram(name, this.histograms);
}
public createCounter(name: string): Counter {
return new StubCounter(name, this.counters);
}
public createObservableGauge(): never {
throw new Error("unused");
}
public createUpDownCounter(): never {
throw new Error("unused");
}
public createObservableCounter(): never {
throw new Error("unused");
}
public createObservableUpDownCounter(): never {
throw new Error("unused");
}
public addBatchObservableCallback(): void {}
public removeBatchObservableCallback(): void {}
public createGauge(): never {
throw new Error("unused");
}
}
class StubMeterProvider implements MeterProvider {
public meters: StubMeter[] = [];
public getMeter(): Meter {
const meter = new StubMeter();
this.meters.push(meter);
return meter;
}
}
describe("storage-browser-opfs otel", () => {
let provider: StubMeterProvider;
beforeEach(() => {
provider = new StubMeterProvider();
setStorageBrowserOpfsMeterProvider(provider);
});
afterEach(() => {
setStorageBrowserOpfsMeterProvider(undefined);
resetInstruments();
});
function findCounter(name: string): RecordedCounter | undefined {
return provider.meters[0]?.counters.find((c) => c.name === name);
}
function findHistogram(name: string): RecordedHistogram | undefined {
return provider.meters[0]?.histograms.find((h) => h.name === name);
}
it("recordOp emits the documented counter and histogram set", {
timeout: 5000,
}, () => {
recordOp({
op: "set",
outcome: "success",
durationMs: 12,
bytes: 100,
direction: "write",
});
const dur = findHistogram("storage_browser_opfs.op_duration_ms");
expect(dur?.samples).toEqual([
{ value: 12, attrs: { op: "set", outcome: "success" } },
]);
const writes = findCounter("storage_browser_opfs.writes_total");
expect(writes?.additions).toEqual([
{ value: 1, attrs: { op: "set", outcome: "success" } },
]);
const io = findHistogram("storage_browser_opfs.io_bytes");
expect(io?.samples).toEqual([
{ value: 100, attrs: { op: "set", direction: "write" } },
]);
});
it("records error counter on a failed op", { timeout: 5000 }, () => {
recordOp({ op: "get", outcome: "error", durationMs: 3 });
const errs = findCounter("storage_browser_opfs.errors_total");
expect(errs?.additions).toEqual([
{ value: 1, attrs: { op: "get", outcome: "error" } },
]);
});
it("records aborted operations on the errors counter", {
timeout: 5000,
}, () => {
recordOp({ op: "get", outcome: "aborted", durationMs: 0 });
expect(
findCounter("storage_browser_opfs.errors_total")?.additions[0]
?.attrs
).toEqual({ op: "get", outcome: "aborted" });
});
it("increments reads/writes/deletes counters by op kind", {
timeout: 5000,
}, () => {
recordOp({ op: "get", outcome: "success", durationMs: 1 });
recordOp({ op: "getPartial", outcome: "success", durationMs: 1 });
recordOp({ op: "getWithMetadata", outcome: "success", durationMs: 1 });
recordOp({ op: "set", outcome: "success", durationMs: 1 });
recordOp({ op: "setPartial", outcome: "success", durationMs: 1 });
recordOp({ op: "delete", outcome: "success", durationMs: 1 });
recordOp({ op: "clear", outcome: "success", durationMs: 1 });
recordOp({ op: "exists", outcome: "success", durationMs: 1 });
recordOp({ op: "list", outcome: "success", durationMs: 1 });
expect(
findCounter("storage_browser_opfs.reads_total")?.additions
).toHaveLength(3);
expect(
findCounter("storage_browser_opfs.writes_total")?.additions
).toHaveLength(2);
expect(
findCounter("storage_browser_opfs.deletes_total")?.additions
).toHaveLength(2);
});
it("returns a cached instrument set on repeat getInstruments calls", {
timeout: 5000,
}, () => {
const a = getInstruments();
const b = getInstruments();
expect(a).toBe(b);
});
it("rebuilds the instrument set when getInstruments(true) is called", {
timeout: 5000,
}, () => {
const a = getInstruments();
const b = getInstruments(true);
expect(a).not.toBe(b);
});
it("omits the io_bytes record when bytes is undefined", {
timeout: 5000,
}, () => {
recordOp({ op: "list", outcome: "success", durationMs: 5 });
expect(
findHistogram("storage_browser_opfs.io_bytes")?.samples ?? []
).toHaveLength(0);
});
it("uses the global metrics fallback when provider is unset", {
timeout: 5000,
}, () => {
setStorageBrowserOpfsMeterProvider(undefined);
// Should not throw — falls back to the global no-op meter provider.
const inst = getInstruments(true);
expect(inst.meter).toBeDefined();
});
});

View File

@@ -0,0 +1,143 @@
import {
type Counter,
type Histogram,
type Meter,
type MeterProvider,
metrics,
type Tracer,
trace,
} from "@opentelemetry/api";
const METER_NAME = "@suaveplan/storage-browser-opfs";
const TRACER_NAME = "@suaveplan/storage-browser-opfs";
let overrideMeterProvider: MeterProvider | undefined;
/**
* Override the {@link MeterProvider} used to acquire the storage-browser-opfs
* meter. Pass `undefined` to fall back to the global provider. The instrument
* cache is rebuilt automatically on next access.
*/
export function setStorageBrowserOpfsMeterProvider(
provider: MeterProvider | undefined
): void {
overrideMeterProvider = provider;
cachedInstruments = undefined;
}
export type StorageBrowserOpfsOp =
| "get"
| "set"
| "delete"
| "exists"
| "list"
| "clear"
| "getMetadata"
| "getWithMetadata"
| "getSize"
| "getPartial"
| "setPartial"
| "getItemSize"
| "createReadStream"
| "createWriteStream";
export type StorageBrowserOpfsOpOutcome = "success" | "error" | "aborted";
export interface StorageBrowserOpfsInstruments {
tracer: Tracer;
meter: Meter;
readsTotal: Counter;
writesTotal: Counter;
deletesTotal: Counter;
errorsTotal: Counter;
opDurationMs: Histogram;
ioBytes: Histogram;
}
let cachedInstruments: StorageBrowserOpfsInstruments | undefined;
/**
* Lazily construct the OTel instrument set so consumers can install a custom
* global `MeterProvider`/`TracerProvider` BEFORE this package's production
* code path executes. Pass `force: true` to rebuild against a freshly-set
* provider; tests use this after `metrics.setGlobalMeterProvider`.
*/
export function getInstruments(force = false): StorageBrowserOpfsInstruments {
if (cachedInstruments !== undefined && !force) return cachedInstruments;
const tracer = trace.getTracer(TRACER_NAME);
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
cachedInstruments = {
tracer,
meter,
readsTotal: meter.createCounter("storage_browser_opfs.reads_total", {
description: "Total successful read operations",
}),
writesTotal: meter.createCounter("storage_browser_opfs.writes_total", {
description: "Total successful write operations",
}),
deletesTotal: meter.createCounter(
"storage_browser_opfs.deletes_total",
{ description: "Total successful delete operations" }
),
errorsTotal: meter.createCounter("storage_browser_opfs.errors_total", {
description: "Total operations that ended in error",
}),
opDurationMs: meter.createHistogram(
"storage_browser_opfs.op_duration_ms",
{
description: "Per-operation duration in milliseconds",
unit: "ms",
}
),
ioBytes: meter.createHistogram("storage_browser_opfs.io_bytes", {
description: "Bytes read or written per operation",
unit: "By",
}),
};
return cachedInstruments;
}
export interface RecordOpInput {
op: StorageBrowserOpfsOp;
outcome: StorageBrowserOpfsOpOutcome;
durationMs: number;
bytes?: number;
direction?: "read" | "write";
}
/**
* Emit a complete observability record for one operation: increments the
* appropriate `{reads,writes,deletes,errors}_total` counter, records a
* duration histogram observation, and (when `bytes` is provided) records an
* `io_bytes` histogram observation.
*/
export function recordOp(input: RecordOpInput): void {
const { op, outcome, durationMs, bytes, direction } = input;
const inst = getInstruments();
const attrs = { op, outcome };
inst.opDurationMs.record(durationMs, attrs);
if (outcome === "error" || outcome === "aborted") {
inst.errorsTotal.add(1, { op, outcome });
} else if (
op === "get" ||
op === "getPartial" ||
op === "getWithMetadata"
) {
inst.readsTotal.add(1, { op, outcome });
} else if (op === "set" || op === "setPartial") {
inst.writesTotal.add(1, { op, outcome });
} else if (op === "delete" || op === "clear") {
inst.deletesTotal.add(1, { op, outcome });
}
if (bytes !== undefined && direction !== undefined) {
inst.ioBytes.record(bytes, { op, direction });
}
}
/**
* Reset the cached instrument set. Tests call this between `it(...)` blocks
* after installing a fresh in-memory MeterProvider.
*/
export function resetInstruments(): void {
cachedInstruments = undefined;
}

View File

@@ -0,0 +1 @@
export { registerOpfsProvider } from "./registry.js";

View File

@@ -0,0 +1,50 @@
# `registry` — factory registration for `OpfsStorage`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Hooks `OpfsStorage` into `@suaveplan/storage`'s factory registry via module augmentation so callers can construct the adapter through `createStorage({ type: "opfs", config })`. Registration is gated behind an explicit `registerOpfsProvider()` function instead of an unconditional side-effect import: `sideEffects: false` bundlers must be able to tree-shake the registry path when a consumer holds a direct `new OpfsStorage(...)` reference but never goes through the factory.
## Features
- `declare module "@suaveplan/storage/factory"` adds typed slots `opfs: OpfsStorage` and `opfs: OpfsStorageOptions`.
- `registerOpfsProvider()` is idempotent — repeat calls are no-ops.
- Re-registers cleanly after `clearStorageProviders()`.
## Usage: Basic Example
```ts
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
import { createStorage } from "@suaveplan/storage/factory";
registerOpfsProvider();
const storage = createStorage({ type: "opfs", config: { directory: "myapp" } });
```
## Usage: Advanced Example
```ts
import {
clearStorageProviders,
createStorage,
hasStorageProvider,
} from "@suaveplan/storage/factory";
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
// In a fresh test isolate:
clearStorageProviders();
registerOpfsProvider();
console.log(hasStorageProvider("opfs")); // true
const storage = createStorage({ type: "opfs", config: { directory: "test" } });
```
## API Reference
- `registerOpfsProvider(): void` — idempotent registration.
## Implementation Notes
The module augmentation is in the same file as the registration function so consumers get the type slot the moment they `import` either symbol. Idempotency is enforced by a `hasStorageProvider("opfs")` short-circuit before re-registering — the underlying factory map handles ties safely either way, but the short-circuit makes the contract explicit and observable.

View File

@@ -0,0 +1,50 @@
import {
clearStorageProviders,
createStorage,
hasStorageProvider,
} from "@suaveplan/storage/factory";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { createInMemoryOpfs } from "../in-memory-opfs/in-memory-opfs.js";
import { OpfsStorage } from "../opfs-storage/opfs-storage.js";
import { registerOpfsProvider } from "./registry.js";
describe("registerOpfsProvider", () => {
beforeEach(() => {
clearStorageProviders();
});
afterEach(() => {
clearStorageProviders();
});
it("registers the opfs provider and resolves it via createStorage", {
timeout: 5000,
}, async () => {
registerOpfsProvider();
expect(hasStorageProvider("opfs")).toBe(true);
const opfs = createInMemoryOpfs();
const storage = createStorage({
type: "opfs" as never,
config: { directory: "t", opfs } as never,
});
expect(storage).toBeInstanceOf(OpfsStorage);
await (storage as OpfsStorage).set("k", new Uint8Array([1]));
expect(await (storage as OpfsStorage).get("k")).toEqual(
new Uint8Array([1])
);
});
it("is idempotent across repeated registerOpfsProvider calls", {
timeout: 5000,
}, () => {
registerOpfsProvider();
registerOpfsProvider();
registerOpfsProvider();
expect(hasStorageProvider("opfs")).toBe(true);
});
});

View File

@@ -0,0 +1,36 @@
import {
hasStorageProvider,
registerStorageProvider,
} from "@suaveplan/storage/factory";
import { OpfsStorage } from "../opfs-storage/opfs-storage.js";
import type { OpfsStorageOptions } from "../types/opfs-storage.types.js";
declare module "@suaveplan/storage/factory" {
interface StorageProviderRegistry {
opfs: OpfsStorage;
}
interface StorageProviderConfig {
opfs: OpfsStorageOptions;
}
}
/**
* Register `OpfsStorage` against the `@suaveplan/storage` factory under the
* canonical type slot `"opfs"`. Idempotent — repeated calls do nothing once
* the slot is occupied, and re-register cleanly after a downstream
* `clearStorageProviders()` call.
*
* Consumers MUST call this explicitly before invoking
* `createStorage({ type: "opfs", ... })`. The package deliberately does NOT
* self-register at module-evaluation time so that `sideEffects: false`
* bundlers can tree-shake the registry path away when not needed.
*/
export function registerOpfsProvider(): void {
if (hasStorageProvider("opfs")) return;
registerStorageProvider(
"opfs",
OpfsStorage as unknown as new (
config?: unknown
) => OpfsStorage
);
}

View File

@@ -0,0 +1,9 @@
export type {
OpfsDirectoryHandle,
OpfsFile,
OpfsFileHandle,
OpfsLike,
OpfsWritableStream,
} from "./opfs-like.types.js";
export type { OpfsStorageOptions } from "./opfs-storage.types.js";
export { OpfsStorageOptionsSchema } from "./opfs-storage.types.js";

View File

@@ -0,0 +1,48 @@
# `opfs-like.types` — structural view of the OPFS API surface
## Purpose
Declares a minimal structural interface (`OpfsLike`, `OpfsDirectoryHandle`, `OpfsFileHandle`, `OpfsFile`, `OpfsWritableStream`) that `OpfsStorage` consumes. Working against a local interface — rather than `lib.dom`'s globals directly — keeps the adapter consumable from packages that compile without DOM lib, lets tests substitute a real in-memory implementation, and pins the contract to the subset of methods we actually call (so future browser additions don't ripple through this package as breakages).
## Features
- One interface per OPFS surface: directory handle, file handle, file, writable stream.
- Every method is a strict subset of the corresponding WHATWG File System Access API method, so the browser globals satisfy our interface without `unknown`-casts.
- `OpfsLike` is the smallest possible root: a single `getDirectory()` method, lazy by design.
## Usage: Basic Example
```ts
import type { OpfsLike } from "@suaveplan/storage-browser-opfs/types";
declare const myOpfs: OpfsLike;
const root = await myOpfs.getDirectory();
```
## Usage: Advanced Example
```ts
import type {
OpfsDirectoryHandle,
OpfsLike,
} from "@suaveplan/storage-browser-opfs/types";
class StaticDirOpfs implements OpfsLike {
public constructor(private readonly handle: OpfsDirectoryHandle) {}
public getDirectory(): Promise<OpfsDirectoryHandle> {
return Promise.resolve(this.handle);
}
}
```
## API Reference
- `OpfsLike.getDirectory(): Promise<OpfsDirectoryHandle>` — resolves the origin's OPFS root.
- `OpfsDirectoryHandle.getFileHandle(name, { create? })` / `getDirectoryHandle(name, { create? })` / `removeEntry(name, { recursive? })` / `entries()`.
- `OpfsFileHandle.getFile()` / `createWritable({ keepExistingData? })`.
- `OpfsFile.size`, `lastModified`, `arrayBuffer()`, `slice(start?, end?)`, `stream()`.
- `OpfsWritableStream extends WritableStream<Uint8Array>` with `seek(position)`.
## Implementation Notes
The interface intentionally omits any method `OpfsStorage` does not call. Adding new operations (e.g. `move`, `remove`) requires updating both this interface and the in-memory test double in lockstep. The native browser types are nominally compatible at the structural level — assignability flows from `FileSystemDirectoryHandle` to `OpfsDirectoryHandle` automatically.

View File

@@ -0,0 +1,56 @@
/**
* Minimal structural view of the WHATWG File System Access API surface used
* by `OpfsStorage`. Declared here rather than relying on `lib.dom`'s global
* `FileSystemDirectoryHandle` because:
* - Universal consumers may opt out of DOM lib in their tsconfig.
* - Tests substitute a real in-memory implementation (no mocks); having a
* local structural interface keeps that contract independent of the
* browser globals' exact shape across browser versions.
*
* Every member here is a strict subset of the corresponding native API.
*/
export interface OpfsFile {
readonly size: number;
readonly lastModified: number;
arrayBuffer(): Promise<ArrayBuffer>;
slice(start?: number, end?: number): OpfsFile;
stream(): ReadableStream<Uint8Array>;
}
export interface OpfsWritableStream extends WritableStream<Uint8Array> {
seek(position: number): Promise<void>;
}
export interface OpfsFileHandle {
readonly kind: "file";
readonly name: string;
getFile(): Promise<OpfsFile>;
createWritable(options?: {
keepExistingData?: boolean;
}): Promise<OpfsWritableStream>;
}
export interface OpfsDirectoryHandle {
readonly kind: "directory";
readonly name: string;
getFileHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsFileHandle>;
getDirectoryHandle(
name: string,
options?: { create?: boolean }
): Promise<OpfsDirectoryHandle>;
removeEntry(name: string, options?: { recursive?: boolean }): Promise<void>;
entries(): AsyncIterable<[string, OpfsFileHandle | OpfsDirectoryHandle]>;
}
/**
* The OPFS root resolver — exactly one method that returns the origin's
* private file system root directory. `defaultOpfs()` wraps
* `navigator.storage.getDirectory()`; tests inject an in-memory variant.
*/
export interface OpfsLike {
getDirectory(): Promise<OpfsDirectoryHandle>;
}

View File

@@ -0,0 +1,46 @@
# `opfs-storage.types` — option boundary for OpfsStorage
## Purpose
Declares the constructor option shape `OpfsStorageOptions` and the matching `OpfsStorageOptionsSchema` Zod schema. The schema is the runtime gate at the constructor boundary — malformed input becomes `OpfsError(OPFS.INVALID_OPTIONS)` with the underlying `ZodError` preserved as `cause`. This keeps invalid configuration from corrupting the OPFS layer with hard-to-diagnose downstream failures.
## Features
- `directory`: caller-overridable sub-directory under OPFS root (default `"storage"`).
- `opfs`: injectable `OpfsLike` adapter for tests and progressive enhancement.
- `clock`: injectable `Clock` so metadata timestamps are deterministic in tests.
- Zod schema accepts structural adapter shapes as `unknown` (Zod cannot fully model interfaces), letting TypeScript enforce the contract at call sites while still validating the literal-typed fields.
## Usage: Basic Example
```ts
import {
OpfsStorage,
type OpfsStorageOptions,
} from "@suaveplan/storage-browser-opfs";
const options: OpfsStorageOptions = { directory: "myapp" };
const storage = new OpfsStorage(options);
```
## Usage: Advanced Example
```ts
import { OpfsStorageOptionsSchema } from "@suaveplan/storage-browser-opfs/types";
function loadOpfsConfigFromEnv(): OpfsStorageOptions {
const raw = JSON.parse(process.env.OPFS_CONFIG ?? "{}");
const parsed = OpfsStorageOptionsSchema.safeParse(raw);
if (!parsed.success) throw parsed.error;
return parsed.data;
}
```
## API Reference
- `interface OpfsStorageOptions { directory?: string; opfs?: OpfsLike; clock?: Clock }`.
- `OpfsStorageOptionsSchema: ZodObject` — `directory: string().min(1).optional()`, structural adapters loose-typed.
## Implementation Notes
The schema-level looseness on `opfs`/`clock` is intentional: Zod cannot fully describe the structural shape of an interface like `OpfsLike` without losing the link to the TypeScript type, and runtime checks of every adapter method would impose a per-call cost without catching anything TypeScript doesn't already catch. The `directory.min(1)` rule is the one boundary that needs runtime enforcement because OPFS itself accepts the empty string and returns the root, which would silently break key isolation.

View File

@@ -0,0 +1,22 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import type { OpfsLike } from "./opfs-like.types.js";
export interface OpfsStorageOptions {
/** Sub-directory name within the OPFS root. Default: `"storage"`. */
directory?: string;
/** Injectable OPFS root resolver. Defaults to `defaultOpfs()`. */
opfs?: OpfsLike;
/** Injectable clock used for synthetic metadata when `lastModified` is unavailable. */
clock?: Clock;
}
// `opfs` and `clock` are structural interfaces; we accept `unknown` on the
// Zod side and rely on TypeScript to enforce shape at the call site.
const looseAdapter = z.unknown().optional();
export const OpfsStorageOptionsSchema = z.object({
directory: z.string().min(1).optional(),
opfs: looseAdapter,
clock: looseAdapter,
});

View File

@@ -0,0 +1,3 @@
// Polyfills for the test environment. Node 20+ exposes WHATWG streams and
// `TextEncoder`/`TextDecoder` globally; nothing else is required for the
// in-memory OPFS test double.

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/storage-browser-opfs",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
/^@suaveplan\/storage/,
"@suaveplan/types",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.spec-conformance.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});