@statewalker/webrun-files 0.10.1 → 0.10.3
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.
- package/README.md +128 -192
- package/dist/index.d.ts +1 -1
- package/package.json +7 -3
- package/src/index.ts +1 -1
package/README.md
CHANGED
|
@@ -1,268 +1,204 @@
|
|
|
1
1
|
# @statewalker/webrun-files
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
+
Backends: `@statewalker/webrun-files-mem`, `-node`, `-browser`, `-s3`, `-sqlite`, `-http`; mounts,
|
|
17
|
+
guards and layers: `@statewalker/webrun-files-composite`.
|
|
23
18
|
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// Create directory (and parents)
|
|
33
|
-
mkdir(path: string): Promise<void>;
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add @statewalker/webrun-files
|
|
23
|
+
```
|
|
34
24
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
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
|
-
|
|
65
|
+
`readFile` and `readText` return an empty result for a missing path; `tryReadFile` and
|
|
66
|
+
`tryReadText` return `undefined` instead.
|
|
72
67
|
|
|
73
|
-
###
|
|
68
|
+
### Random access
|
|
74
69
|
|
|
75
70
|
```typescript
|
|
76
|
-
import {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
###
|
|
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(
|
|
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');
|
|
87
|
+
console.log('a directory'); // nothing else to report
|
|
148
88
|
}
|
|
149
89
|
```
|
|
150
90
|
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
98
|
+
const page: FileInfo[] = [];
|
|
192
99
|
for await (const entry of files.list('/data', { recursive: true, after })) {
|
|
193
|
-
|
|
194
|
-
if (
|
|
100
|
+
page.push(entry);
|
|
101
|
+
if (page.length === 256) break;
|
|
195
102
|
}
|
|
196
|
-
if (
|
|
197
|
-
await handle(
|
|
198
|
-
after =
|
|
103
|
+
if (page.length === 0) break;
|
|
104
|
+
await handle(page);
|
|
105
|
+
after = page[page.length - 1].path;
|
|
199
106
|
}
|
|
200
107
|
```
|
|
201
108
|
|
|
202
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
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
|
-
|
|
137
|
+
### Types
|
|
233
138
|
|
|
234
139
|
```typescript
|
|
235
140
|
import type {
|
|
236
|
-
FilesApi,
|
|
237
|
-
FileStats,
|
|
238
|
-
FileEntryStats,
|
|
239
|
-
DirectoryEntryStats,
|
|
240
|
-
FileInfo,
|
|
241
|
-
FileEntryInfo,
|
|
242
|
-
DirectoryEntryInfo,
|
|
243
|
-
FileEntryLocation,
|
|
244
|
-
FileKind,
|
|
245
|
-
ReadOptions,
|
|
246
|
-
ListOptions,
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - @statewalker/webrun-files-node (Node.js)
|
|
12
12
|
* - @statewalker/webrun-files-browser (browser)
|
|
13
13
|
* - @statewalker/webrun-files-s3 (S3)
|
|
14
|
-
* - @statewalker/webrun-files-sqlite (SQLite: node:sqlite, Durable Objects
|
|
14
|
+
* - @statewalker/webrun-files-sqlite (SQLite over @statewalker/db-api: node:sqlite, browser, Durable Objects)
|
|
15
15
|
* - @statewalker/webrun-files-http (a FilesApi served and consumed over HTTP)
|
|
16
16
|
* - @statewalker/webrun-files-composite (mount several backends as one tree)
|
|
17
17
|
*/
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-files",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "FilesApi interface and utilities for cross-platform filesystem abstraction",
|
|
7
|
-
"
|
|
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
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - @statewalker/webrun-files-node (Node.js)
|
|
12
12
|
* - @statewalker/webrun-files-browser (browser)
|
|
13
13
|
* - @statewalker/webrun-files-s3 (S3)
|
|
14
|
-
* - @statewalker/webrun-files-sqlite (SQLite: node:sqlite, Durable Objects
|
|
14
|
+
* - @statewalker/webrun-files-sqlite (SQLite over @statewalker/db-api: node:sqlite, browser, Durable Objects)
|
|
15
15
|
* - @statewalker/webrun-files-http (a FilesApi served and consumed over HTTP)
|
|
16
16
|
* - @statewalker/webrun-files-composite (mount several backends as one tree)
|
|
17
17
|
*/
|