@statewalker/webrun-files 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +128 -192
  2. package/package.json +7 -3
package/README.md CHANGED
@@ -1,268 +1,204 @@
1
1
  # @statewalker/webrun-files
2
2
 
3
- Core types and utilities for cross-platform file operations. This package defines the `FilesApi` interface that all storage backends implement, plus utility functions for common file operations.
3
+ ## What it is
4
4
 
5
- ## Installation
5
+ The `FilesApi` interface for file storage, its metadata types, and helpers that work on any
6
+ implementation: whole-file and range reads, text writes, path manipulation, and the listing order
7
+ every implementation follows. It contains no storage backend.
6
8
 
7
- ```bash
8
- npm install @statewalker/webrun-files
9
- ```
10
-
11
- For actual filesystem implementations, install one of these packages:
12
- - `@statewalker/webrun-files-mem` - In-memory storage
13
- - `@statewalker/webrun-files-node` - Node.js filesystem
14
- - `@statewalker/webrun-files-browser` - Browser File System Access API
15
- - `@statewalker/webrun-files-s3` - AWS S3 / S3-compatible storage
16
- - `@statewalker/webrun-files-sqlite` - SQLite (node:sqlite, Cloudflare Durable Objects, D1)
17
- - `@statewalker/webrun-files-http` - Serve and consume a `FilesApi` over HTTP (fetch-based stubs)
18
- - `@statewalker/webrun-files-composite` - Mount multiple backends into a unified filesystem
9
+ ## Why it exists
19
10
 
20
- ## The FilesApi Interface
11
+ Code that reads and writes files should not care whether the files live in memory, on disk, in a
12
+ browser directory, in S3, in SQLite or behind an HTTP endpoint. This package is the contract those
13
+ backends implement, so application code, tests and decorators are written once against it. Keeping
14
+ it backend-free means depending on it pulls in nothing runtime-specific.
21
15
 
22
- All implementations provide this interface:
16
+ Backends: `@statewalker/webrun-files-mem`, `-node`, `-browser`, `-s3`, `-sqlite`, `-http`; mounts,
17
+ guards and layers: `@statewalker/webrun-files-composite`.
23
18
 
24
- ```typescript
25
- interface FilesApi {
26
- // Read file content as async iterable of chunks
27
- read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
19
+ ## How to use
28
20
 
29
- // Write content to file (creates parent directories)
30
- write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
31
-
32
- // Create directory (and parents)
33
- mkdir(path: string): Promise<void>;
21
+ ```bash
22
+ pnpm add @statewalker/webrun-files
23
+ ```
34
24
 
35
- // List directory contents
36
- list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
25
+ One entry point, `@statewalker/webrun-files`: ESM (`dist/esm/index.js`), CommonJS
26
+ (`dist/cjs/index.cjs`), types (`dist/index.d.ts`); the TypeScript sources ship in `src/`. It has no
27
+ runtime-specific imports and runs in browsers, workers and Node.js.
37
28
 
38
- // Get file/directory metadata
29
+ ```typescript
30
+ interface FilesApi {
31
+ read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>; // missing path: yields nothing
32
+ write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>; // creates parents
33
+ mkdir(path: string): Promise<void>; // creates parents
34
+ list(path: string, options?: ListOptions): AsyncIterable<FileInfo>; // in path order
39
35
  stats(path: string): Promise<FileStats | undefined>;
40
-
41
- // Check if path exists
42
36
  exists(path: string): Promise<boolean>;
43
-
44
- // Remove file or directory (recursively)
45
- remove(path: string): Promise<boolean>;
46
-
47
- // Move/rename file or directory
37
+ remove(path: string): Promise<boolean>; // recursive
48
38
  move(source: string, target: string): Promise<boolean>;
49
-
50
- // Copy file or directory (recursively)
51
- copy(source: string, target: string): Promise<boolean>;
39
+ copy(source: string, target: string): Promise<boolean>; // recursive
52
40
  }
41
+
42
+ interface ReadOptions { start?: number; length?: number; signal?: AbortSignal }
43
+ interface ListOptions { recursive?: boolean; after?: string }
53
44
  ```
54
45
 
55
- ## Quick Start
46
+ ## Examples
47
+
48
+ ### Read and write whole files
56
49
 
57
50
  ```typescript
58
51
  import { MemFilesApi } from '@statewalker/webrun-files-mem';
59
- import { readFile, writeText } from '@statewalker/webrun-files';
52
+ import { readFile, readText, tryReadText, writeText } from '@statewalker/webrun-files';
60
53
 
61
54
  const files = new MemFilesApi();
62
-
63
- // Write a file
64
55
  await writeText(files, '/documents/notes.txt', 'Remember to water the plants');
65
56
 
66
- // Read it back
67
- const content = await readFile(files, '/documents/notes.txt');
68
- console.log(new TextDecoder().decode(content));
57
+ const bytes = await readFile(files, '/documents/notes.txt'); // Uint8Array
58
+ const text = await readText(files, '/documents/notes.txt'); // UTF-8 string
59
+ const maybe = await tryReadText(files, '/optional.txt'); // undefined when missing
60
+
61
+ // Binary content goes straight to write(), as chunks
62
+ await files.write('/data.bin', [new Uint8Array([1, 2, 3, 4])]);
69
63
  ```
70
64
 
71
- ## Utility Functions
65
+ `readFile` and `readText` return an empty result for a missing path; `tryReadFile` and
66
+ `tryReadText` return `undefined` instead.
72
67
 
73
- ### Reading Files
68
+ ### Random access
74
69
 
75
70
  ```typescript
76
- import { readFile, readText, tryReadFile, tryReadText, readRange, readAt } from '@statewalker/webrun-files';
77
-
78
- // Read entire file as Uint8Array
79
- const content = await readFile(files, '/data.bin');
80
-
81
- // Read as UTF-8 string
82
- const text = await readText(files, '/config.json');
71
+ import { readAt, readRange } from '@statewalker/webrun-files';
83
72
 
84
- // Read with undefined return for missing files
85
- const maybeContent = await tryReadFile(files, '/optional.txt');
86
- const maybeText = await tryReadText(files, '/optional.txt');
73
+ const chunk = await readRange(files, '/large.bin', 1000, 500); // 500 bytes from position 1000
87
74
 
88
- // Read a specific byte range
89
- const chunk = await readRange(files, '/large.bin', 1000, 500); // 500 bytes starting at position 1000
90
-
91
- // Read into a buffer at specific offset (like fs.read)
75
+ // Like fs.read: (files, path, buffer, bufferOffset, length, position) -> bytes read
92
76
  const buffer = new Uint8Array(100);
93
77
  const bytesRead = await readAt(files, '/data.bin', buffer, 0, 100, 500);
94
78
  ```
95
79
 
96
- ### Writing Files
97
-
98
- ```typescript
99
- import { writeText } from '@statewalker/webrun-files';
100
-
101
- // Write string content as UTF-8
102
- await writeText(files, '/greeting.txt', 'Hello, World!');
103
-
104
- // Write binary content directly
105
- await files.write('/data.bin', [new Uint8Array([1, 2, 3, 4])]);
106
-
107
- // Write from multiple chunks
108
- await files.write('/large.bin', generateChunks());
109
- ```
110
-
111
- ### Path Utilities
112
-
113
- ```typescript
114
- import { normalizePath, joinPath, dirname, basename, extname } from '@statewalker/webrun-files';
115
-
116
- normalizePath('//foo/./bar//baz/'); // '/foo/bar/baz'
117
- joinPath('/foo', 'bar', 'baz.txt'); // '/foo/bar/baz.txt'
118
- dirname('/foo/bar/baz.txt'); // '/foo/bar'
119
- basename('/foo/bar/baz.txt'); // 'baz.txt'
120
- extname('/foo/bar/baz.txt'); // '.txt'
121
- ```
122
-
123
- ## Working with Files
124
-
125
- ### Checking Existence
126
-
127
- ```typescript
128
- if (await files.exists('/config.json')) {
129
- // Load configuration
130
- }
131
- ```
132
-
133
- ### Getting Metadata
134
-
135
- `FileStats` is a discriminated union on `kind`. A file always reports a `size`
136
- and a `lastModified`; a directory reports neither, because a directory has no
137
- size and a modification time for one is unavailable on stores where a directory
138
- is only a key prefix. Narrow on `kind` and the fields for that kind are then
139
- known to be present:
80
+ ### Metadata: narrow on `kind`
140
81
 
141
82
  ```typescript
142
83
  const info = await files.stats('/photo.jpg');
143
84
  if (info?.kind === 'file') {
144
- console.log(`Size: ${info.size} bytes`); // number, never undefined
145
- console.log(`Modified: ${new Date(info.lastModified)}`);
85
+ console.log(info.size, new Date(info.lastModified)); // both always present
146
86
  } else if (info?.kind === 'directory') {
147
- console.log('a directory'); // nothing else to report
87
+ console.log('a directory'); // nothing else to report
148
88
  }
149
89
  ```
150
90
 
151
- A zero-byte file is the file variant with `size: 0`, so test the `kind` rather
152
- than the truthiness of `size`:
153
-
154
- ```typescript
155
- // WRONG - reads an empty file as though it had no size
156
- if (info.size) { /* ... */ }
157
-
158
- // RIGHT
159
- if (info.kind === 'file') { /* info.size may legitimately be 0 */ }
160
- ```
161
-
162
- ### Listing Directories
163
-
164
- `FileInfo` is the same union plus `name` and `path`, so a listing narrows per
165
- entry exactly as `stats()` does:
91
+ ### List a directory in pages
166
92
 
167
93
  ```typescript
168
- // List direct children
169
- for await (const entry of files.list('/documents')) {
170
- const size = entry.kind === 'file' ? `${entry.size} bytes` : '';
171
- console.log(`${entry.name} (${entry.kind}) ${size}`);
172
- }
173
-
174
- // List recursively
175
- for await (const entry of files.list('/project', { recursive: true })) {
176
- console.log(entry.path);
177
- }
178
- ```
179
-
180
- ### Listing order and resuming
181
-
182
- Every implementation yields `list()` entries in **strictly increasing path order, compared by
183
- Unicode code point** — the order of UTF-8 bytes, which is also how SQLite and S3 list — for
184
- recursive and non-recursive listings alike. `ListOptions.after` resumes after any path, whether or
185
- not it exists:
94
+ import type { FileInfo } from '@statewalker/webrun-files';
186
95
 
187
- ```typescript
188
- // Read a big directory in chunks of 256 without keeping an iterator open.
189
96
  let after: string | undefined;
190
97
  for (;;) {
191
- const chunk: FileInfo[] = [];
98
+ const page: FileInfo[] = [];
192
99
  for await (const entry of files.list('/data', { recursive: true, after })) {
193
- chunk.push(entry);
194
- if (chunk.length === 256) break;
100
+ page.push(entry);
101
+ if (page.length === 256) break;
195
102
  }
196
- if (chunk.length === 0) break;
197
- await handle(chunk);
198
- after = chunk[chunk.length - 1].path;
103
+ if (page.length === 0) break;
104
+ await handle(page);
105
+ after = page[page.length - 1].path;
199
106
  }
200
107
  ```
201
108
 
202
- Two consequences of the order: a directory comes before its descendants, but they are not
203
- contiguous with it (`/a-x` and `/a.txt` sort between `/a` and `/a/b`, because `-` and `.` sort
204
- before `/`); and JavaScript's `<` is not this order — it misorders characters above U+FFFF — so
205
- compare paths with `comparePaths`.
206
-
207
- ### File Management
109
+ ### Paths
208
110
 
209
111
  ```typescript
210
- await files.mkdir('/archive/2024');
211
- await files.copy('/report.pdf', '/archive/2024/report.pdf');
212
- await files.move('/temp/draft.txt', '/documents/final.txt');
213
- await files.remove('/old-stuff'); // Recursively deletes directories
112
+ import { basename, dirname, extname, joinPath, normalizePath } from '@statewalker/webrun-files';
113
+
114
+ normalizePath('//foo/./bar//baz/'); // '/foo/bar/baz'
115
+ joinPath('/foo', 'bar', 'baz.txt'); // '/foo/bar/baz.txt'
116
+ dirname('/foo/bar/baz.txt'); // '/foo/bar'
117
+ basename('/foo/bar/baz.txt'); // 'baz.txt'
118
+ basename('/foo/bar/baz.txt', '.txt'); // 'baz'
119
+ extname('/foo/bar/baz.txt'); // '.txt'
214
120
  ```
215
121
 
216
- ### Listing helpers for backends
122
+ ### Listing helpers for backend authors
217
123
 
218
124
  ```typescript
219
125
  import { comparePaths, listInPathOrder, mergeInPathOrder } from '@statewalker/webrun-files';
220
126
 
221
127
  comparePaths(a, b); // the listing order: negative, zero or positive
222
128
 
223
- // A backend that reads one directory at a time: children(dir) returns its direct
224
- // entries in any order. Reads a directory only when the listing reaches it, and
225
- // never one whose whole subtree sorts at or before options.after.
226
- listInPathOrder(dir, children, { recursive, after });
129
+ // children(dir) returns one directory's direct entries, in any order. The generator reads a
130
+ // directory only when the listing reaches it, and skips any subtree that sorts at or before `after`.
131
+ listInPathOrder('/', children, { recursive: true, after });
227
132
 
228
133
  // Merge listings that are each already ordered; the first stream wins a shared path.
229
134
  mergeInPathOrder([streamA, streamB]);
230
135
  ```
231
136
 
232
- ## Type Reference
137
+ ### Types
233
138
 
234
139
  ```typescript
235
140
  import type {
236
- FilesApi, // Core interface for backends
237
- FileStats, // Metadata from stats(): FileEntryStats | DirectoryEntryStats
238
- FileEntryStats, // { kind: "file", size: number, lastModified: number }
239
- DirectoryEntryStats, // { kind: "directory" }
240
- FileInfo, // Metadata from list(): FileEntryInfo | DirectoryEntryInfo
241
- FileEntryInfo, // FileEntryStats & { name, path }
242
- DirectoryEntryInfo, // DirectoryEntryStats & { name, path }
243
- FileEntryLocation, // { name: string, path: string }
244
- FileKind, // "file" | "directory"
245
- ReadOptions, // { start?: number, length?: number, signal?: AbortSignal }
246
- ListOptions, // { recursive?: boolean, after?: string }
141
+ FilesApi,
142
+ FileStats, // FileEntryStats | DirectoryEntryStats
143
+ FileEntryStats, // { kind: "file"; size: number; lastModified: number }
144
+ DirectoryEntryStats, // { kind: "directory" }
145
+ FileInfo, // FileEntryInfo | DirectoryEntryInfo
146
+ FileEntryInfo, // FileEntryStats & FileEntryLocation
147
+ DirectoryEntryInfo, // DirectoryEntryStats & FileEntryLocation
148
+ FileEntryLocation, // { name: string; path: string }
149
+ FileKind, // "file" | "directory"
150
+ ReadOptions,
151
+ ListOptions,
247
152
  } from '@statewalker/webrun-files';
248
153
  ```
249
154
 
250
- ### Implementing a backend
251
-
252
- An implementation must return **exactly** one variant: a file with both numbers
253
- present, a directory with nothing but its `kind`. An implementation that knows a
254
- directory's modification time drops it rather than offering a value that would
255
- be present on one backend and missing on the next. The parametrized suite in
256
- `@statewalker/webrun-files-tests` — a workspace package of this monorepo, not
257
- published to npm — checks this at runtime: `createFilesApiTests` runs it (as
258
- `createFileStatsConformanceTests`) for you.
259
-
260
- A listing must also follow the order above and honour `after` exactly: the
261
- entries after a path are the same, in the same order, as the matching suffix of
262
- the full listing. `createFilesApiTests` checks that too
263
- (`createListOrderTests`), against a fixture of names chosen to break naive
264
- orderings. `listInPathOrder` gives both properties to a backend that can read
265
- one directory at a time.
155
+ ## Internals
156
+
157
+ ### Why metadata is a union and not optional fields
158
+
159
+ A file always reports `size` and `lastModified`. A directory reports neither: it has no size, and
160
+ on stores where a directory is only a key prefix there is no modification time to give. An
161
+ implementation that knows a directory's time drops it, so callers cannot come to rely on a field
162
+ that one backend has and the next lacks. A zero-byte file is the file variant with `size: 0`:
163
+
164
+ ```typescript
165
+ if (info.size) { /* WRONG: treats an empty file as having no size */ }
166
+ if (info.kind === 'file') { /* RIGHT: info.size may be 0 */ }
167
+ ```
168
+
169
+ ### Why listings have one global order
170
+
171
+ Every implementation yields `list()` entries in strictly increasing path order, compared by Unicode
172
+ code point. That is UTF-8 byte order, which SQLite and S3 already list in, so those backends can
173
+ page through their native listings. `ListOptions.after` resumes after any path, present or not, so
174
+ a client can read a large or remote listing in chunks without an open iterator.
175
+
176
+ Two consequences:
177
+
178
+ - A directory comes before its descendants, but they are not contiguous with it: `/a-x` and
179
+ `/a.txt` sort between `/a` and `/a/b`, because `-` and `.` sort before `/`.
180
+ - JavaScript's `<` compares UTF-16 code units and misorders characters above U+FFFF. Use
181
+ `comparePaths`.
182
+
183
+ ### What a backend must do
184
+
185
+ - Return exactly one metadata variant: a file with both numbers, a directory with only `kind`.
186
+ - List in the order above and honour `after` exactly: the entries after a path are the same, in
187
+ the same order, as the matching suffix of the full listing.
188
+
189
+ The shared suites in `@statewalker/webrun-files-tests` (a private workspace package) check both at
190
+ runtime. `listInPathOrder` gives both properties to a backend that can read one directory at a
191
+ time.
192
+
193
+ ### Path rules
194
+
195
+ `normalizePath` splits on `/`, drops empty and `.` segments and returns a path with one leading
196
+ slash and no trailing slash (`/` for the root). It does not resolve `..`; backends that map paths
197
+ to real locations (such as `webrun-files-node`) document what that means for them.
198
+
199
+ ### Dependencies
200
+
201
+ None at runtime.
266
202
 
267
203
  ## License
268
204
 
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "FilesApi interface and utilities for cross-platform filesystem abstraction",
7
- "homepage": "https://github.com/statewalker/webrun-files",
7
+ "bugs": {
8
+ "url": "https://github.com/statewalker/webrun-files/issues"
9
+ },
10
+ "homepage": "https://github.com/statewalker/webrun-files/tree/main/packages/webrun-files#readme",
8
11
  "author": {
9
12
  "name": "Mikhail Kotelnikov",
10
13
  "email": "mikhail.kotelnikov@gmail.com"
@@ -12,7 +15,8 @@
12
15
  "license": "MIT",
13
16
  "repository": {
14
17
  "type": "git",
15
- "url": "git@github.com:statewalker/webrun-files.git"
18
+ "url": "git+https://github.com/statewalker/webrun-files.git",
19
+ "directory": "packages/webrun-files"
16
20
  },
17
21
  "main": "./dist/cjs/index.cjs",
18
22
  "module": "./dist/esm/index.js",