@anthropic-ai/foundry-sdk 0.4.3 → 0.4.5
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/CHANGELOG.md +16 -0
- package/README.md +3 -3
- package/internal/errors.d.mts.map +1 -1
- package/internal/errors.d.ts.map +1 -1
- package/internal/errors.js +3 -1
- package/internal/errors.js.map +1 -1
- package/internal/errors.mjs +3 -1
- package/internal/errors.mjs.map +1 -1
- package/internal/file-store.d.mts +136 -0
- package/internal/file-store.d.mts.map +1 -0
- package/internal/file-store.d.ts +136 -0
- package/internal/file-store.d.ts.map +1 -0
- package/internal/file-store.js +529 -0
- package/internal/file-store.js.map +1 -0
- package/internal/file-store.mjs +522 -0
- package/internal/file-store.mjs.map +1 -0
- package/internal/node.browser.d.mts +9 -0
- package/internal/node.browser.d.mts.map +1 -0
- package/internal/node.browser.d.ts +9 -0
- package/internal/node.browser.d.ts.map +1 -0
- package/internal/node.browser.js +22 -0
- package/internal/node.browser.js.map +1 -0
- package/internal/node.browser.mjs +19 -0
- package/internal/node.browser.mjs.map +1 -0
- package/internal/node.d.mts +14 -0
- package/internal/node.d.mts.map +1 -0
- package/internal/node.d.ts +14 -0
- package/internal/node.d.ts.map +1 -0
- package/internal/node.js +56 -0
- package/internal/node.js.map +1 -0
- package/internal/node.mjs +14 -0
- package/internal/node.mjs.map +1 -0
- package/internal/parse.d.mts +2 -1
- package/internal/parse.d.mts.map +1 -1
- package/internal/parse.d.ts +2 -1
- package/internal/parse.d.ts.map +1 -1
- package/internal/parse.js +7 -7
- package/internal/parse.js.map +1 -1
- package/internal/parse.mjs +6 -6
- package/internal/parse.mjs.map +1 -1
- package/internal/uploads.js +10 -6
- package/internal/uploads.js.map +1 -1
- package/internal/uploads.mjs +10 -6
- package/internal/uploads.mjs.map +1 -1
- package/internal/utils/values.d.mts +6 -0
- package/internal/utils/values.d.mts.map +1 -1
- package/internal/utils/values.d.ts +6 -0
- package/internal/utils/values.d.ts.map +1 -1
- package/internal/utils/values.js +7 -0
- package/internal/utils/values.js.map +1 -1
- package/internal/utils/values.mjs +6 -0
- package/internal/utils/values.mjs.map +1 -1
- package/package.json +1 -11
- package/src/internal/detect-platform.ts +1 -1
- package/src/internal/errors.ts +3 -1
- package/src/internal/file-store.ts +551 -0
- package/src/internal/node.browser.ts +22 -0
- package/src/internal/node.ts +14 -0
- package/src/internal/parse.ts +7 -7
- package/src/internal/uploads.ts +10 -7
- package/src/internal/utils/values.ts +7 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FileStore` — one confined folder; a relative path cannot escape it.
|
|
3
|
+
*
|
|
4
|
+
* Beta scope: symlinks are refused or skipped wherever the store meets them,
|
|
5
|
+
* but there is no hardening against a process racing the store's own
|
|
6
|
+
* syscalls; fsync durability, non-POSIX hosts, and read-size caps are out of
|
|
7
|
+
* scope.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { BigIntStats, Dirent } from 'node:fs';
|
|
11
|
+
import type { FileHandle } from 'node:fs/promises';
|
|
12
|
+
import type * as util from 'node:util';
|
|
13
|
+
import { crypto, fs, path } from './node';
|
|
14
|
+
import { encodeUTF8 } from './utils/bytes';
|
|
15
|
+
|
|
16
|
+
const fsp = fs.promises;
|
|
17
|
+
const C = fs.constants;
|
|
18
|
+
|
|
19
|
+
// Owner-only regardless of umask: the store holds downloaded user/model content.
|
|
20
|
+
const OWNER_ONLY_DIR_MODE = 0o700;
|
|
21
|
+
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
22
|
+
const OWNER_ONLY_EXEC_MODE = 0o700;
|
|
23
|
+
|
|
24
|
+
// 0 where the platform lacks them; `open` refuses such platforms.
|
|
25
|
+
const O_NOFOLLOW: number = (C as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0;
|
|
26
|
+
const O_NONBLOCK: number = (C as { O_NONBLOCK?: number }).O_NONBLOCK ?? 0;
|
|
27
|
+
|
|
28
|
+
/** A refused operation — input the store will not act on. OS errors propagate with their `.code`. */
|
|
29
|
+
export class FileStoreError extends Error {
|
|
30
|
+
static readonly ESCAPES_ROOT = 'escapes the store root';
|
|
31
|
+
static readonly IS_A_SYMLINK = 'is a symlink';
|
|
32
|
+
static readonly NOT_A_FILE = 'is not a regular file';
|
|
33
|
+
static readonly NOT_A_DIRECTORY = 'is not a directory';
|
|
34
|
+
static readonly NOT_UTF8 = 'is not valid utf-8';
|
|
35
|
+
static readonly MOVE_DESTINATION_EXISTS = 'already exists';
|
|
36
|
+
|
|
37
|
+
readonly reason: string;
|
|
38
|
+
readonly relPath: string;
|
|
39
|
+
|
|
40
|
+
constructor(reason: string, relPath: string) {
|
|
41
|
+
super(`path ${JSON.stringify(relPath)} ${reason}`);
|
|
42
|
+
this.name = 'FileStoreError';
|
|
43
|
+
this.reason = reason;
|
|
44
|
+
this.relPath = relPath;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The store's resolved root, and what {@link FileStore.dispose} will do to it.
|
|
50
|
+
*
|
|
51
|
+
* `removedOnDispose` is true when `open` found no root, so `dispose` removes
|
|
52
|
+
* it. A root that was already there is someone else's — a pre-seeded mount, a
|
|
53
|
+
* caller's workdir — and is kept.
|
|
54
|
+
*/
|
|
55
|
+
export type Root = { path: string; removedOnDispose: boolean };
|
|
56
|
+
|
|
57
|
+
/** Options for {@link FileStore.open} / {@link openFileStore}. */
|
|
58
|
+
export interface OpenFileStoreOptions {
|
|
59
|
+
/**
|
|
60
|
+
* Restrict the store to valid UTF-8 (default `false`): a `put` of binary
|
|
61
|
+
* bytes and a `get` of a binary file are refused with
|
|
62
|
+
* {@link FileStoreError}, so a caller that decodes what `get` returns can
|
|
63
|
+
* never hit a decode error.
|
|
64
|
+
*/
|
|
65
|
+
utf8?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolve `root`; creates nothing — only {@link FileStore.createRoot} makes the folder. */
|
|
69
|
+
export async function openFileStore(root: string, opts?: OpenFileStoreOptions): Promise<FileStore> {
|
|
70
|
+
return FileStore.open(root, opts);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True for a path usable verbatim as a store location: absolute, with no `..`
|
|
75
|
+
* components. Paths are judged in POSIX terms — they are wire values naming
|
|
76
|
+
* locations inside a POSIX container, not host-native paths.
|
|
77
|
+
*/
|
|
78
|
+
export function isPathLegal(p: string): boolean {
|
|
79
|
+
return p.startsWith('/') && !p.split('/').includes('..');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One hashed file version: the stat identity it had, and its sha. */
|
|
83
|
+
type Hashed = {
|
|
84
|
+
mtimeNs: bigint;
|
|
85
|
+
// Userspace cannot set ctime, so writers that preserve mtimes
|
|
86
|
+
// (`rsync -t`, `cp -p`) still miss the cache.
|
|
87
|
+
ctimeNs: bigint;
|
|
88
|
+
size: bigint;
|
|
89
|
+
sha: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One confined folder of regular files.
|
|
94
|
+
*
|
|
95
|
+
* Every `relPath` is relative to the root (a leading `/` also means the root)
|
|
96
|
+
* and refused with {@link FileStoreError} when it escapes. The store holds
|
|
97
|
+
* regular files only: symlinks are refused on read and skipped by listings —
|
|
98
|
+
* {@link findSymlinks} reports them. A `relPath` resolving to the root itself
|
|
99
|
+
* is banned by this interface: `put` and `get` refuse it, `move` and `remove`
|
|
100
|
+
* do nothing. A store opened with `utf8: true` refuses binary content the
|
|
101
|
+
* same way — on `put` of such bytes and on `get` of such a file. Only
|
|
102
|
+
* {@link createRoot} makes the root: writes create directories below it,
|
|
103
|
+
* never the root itself, so a root removed while the store is open stays
|
|
104
|
+
* removed and the write fails with `ENOENT`.
|
|
105
|
+
*/
|
|
106
|
+
export class FileStore {
|
|
107
|
+
private readonly rootPath: string;
|
|
108
|
+
/** True ⇒ this open created the root, so {@link dispose} removes it. */
|
|
109
|
+
private readonly removedOnDispose: boolean;
|
|
110
|
+
/** Set iff the store refuses non-UTF-8 content; {@link requireUtf8} decodes against it, on both put and get. */
|
|
111
|
+
private readonly decoder: util.TextDecoder | undefined;
|
|
112
|
+
/** `hashtree`'s advisory cache; every hit re-validates against a fresh stat. */
|
|
113
|
+
private readonly hashes = new Map<string, Hashed>();
|
|
114
|
+
|
|
115
|
+
static isPathLegal = isPathLegal;
|
|
116
|
+
|
|
117
|
+
/** @internal — use {@link FileStore.open} / {@link openFileStore}. */
|
|
118
|
+
constructor(root: string, removedOnDispose: boolean, utf8Only: boolean = false) {
|
|
119
|
+
this.rootPath = root;
|
|
120
|
+
this.removedOnDispose = removedOnDispose;
|
|
121
|
+
this.decoder = utf8Only ? new TextDecoder('utf-8', { fatal: true }) : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Resolve `root`; creates nothing — only {@link createRoot} makes the folder. */
|
|
125
|
+
static async open(root: string, opts?: OpenFileStoreOptions): Promise<FileStore> {
|
|
126
|
+
// A deployment condition, not refused caller input — hence not FileStoreError.
|
|
127
|
+
if (!platformSupported()) {
|
|
128
|
+
throw new Error('FileStore requires O_NOFOLLOW support on this platform');
|
|
129
|
+
}
|
|
130
|
+
let removedOnDispose = false;
|
|
131
|
+
try {
|
|
132
|
+
// lstat, not a follow-and-swallow existence check: following symlinks or
|
|
133
|
+
// swallowing permission errors would mark a real directory ours to
|
|
134
|
+
// delete on dispose.
|
|
135
|
+
await fsp.lstat(root);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
|
|
138
|
+
removedOnDispose = true;
|
|
139
|
+
}
|
|
140
|
+
return new FileStore(path.resolve(root), removedOnDispose, opts?.utf8 ?? false);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Create the root directory and any missing ancestors; already existing is fine. */
|
|
144
|
+
async createRoot(): Promise<void> {
|
|
145
|
+
await makeDirAndAncestors(this.rootPath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The resolved root, and what {@link dispose} will do to it. */
|
|
149
|
+
root(): Root {
|
|
150
|
+
return { path: this.rootPath, removedOnDispose: this.removedOnDispose };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove the root iff `open` created it; pre-existing roots are kept.
|
|
155
|
+
*
|
|
156
|
+
* Wired to `Symbol.asyncDispose` at runtime when the host provides it, so
|
|
157
|
+
* `await using` works on engines with explicit resource management.
|
|
158
|
+
*/
|
|
159
|
+
async dispose(): Promise<void> {
|
|
160
|
+
if (!this.removedOnDispose) return;
|
|
161
|
+
await fsp.rm(this.rootPath, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Write `data` (`string` UTF-8 or bytes) atomically to the file at `relPath`.
|
|
166
|
+
*
|
|
167
|
+
* Missing directories below the root are created; a missing root is not —
|
|
168
|
+
* the write fails with `ENOENT`.
|
|
169
|
+
*/
|
|
170
|
+
async put(relPath: string, data: string | Uint8Array, opts?: { executable?: boolean }): Promise<void> {
|
|
171
|
+
// "dir/." names a directory just like a trailing "/".
|
|
172
|
+
const tail = relPath.replace(/\\/g, '/');
|
|
173
|
+
if (tail.endsWith('/') || tail.endsWith('/.') || tail === '' || tail === '.') {
|
|
174
|
+
throw new FileStoreError(FileStoreError.NOT_A_FILE, relPath);
|
|
175
|
+
}
|
|
176
|
+
const dest = this.resolveUnderRoot(relPath);
|
|
177
|
+
const payload = typeof data === 'string' ? encodeUTF8(data) : data;
|
|
178
|
+
this.requireUtf8(relPath, payload);
|
|
179
|
+
await makeDirsBelowRoot(this.rootPath, path.dirname(dest));
|
|
180
|
+
await replaceViaTemp(dest, payload, opts?.executable ?? false);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The file's bytes; `null` when absent. */
|
|
184
|
+
async get(relPath: string): Promise<Uint8Array | null> {
|
|
185
|
+
const dest = this.resolveUnderRoot(relPath);
|
|
186
|
+
let handle: FileHandle;
|
|
187
|
+
try {
|
|
188
|
+
handle = await openRegularFile(relPath, dest);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
let data: Uint8Array;
|
|
194
|
+
try {
|
|
195
|
+
const buf = await handle.readFile();
|
|
196
|
+
data = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
197
|
+
} finally {
|
|
198
|
+
await handle.close();
|
|
199
|
+
}
|
|
200
|
+
this.requireUtf8(relPath, data);
|
|
201
|
+
return data;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The relative path of every file under the directory `under`. */
|
|
205
|
+
async ls(under: string = '/'): Promise<Set<string>> {
|
|
206
|
+
const base = this.resolveUnderRoot(under);
|
|
207
|
+
return new Set((await filenamesInDir(this.rootPath, under, base)).map(([rel]) => rel));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Every symlink under `under` — listings skip them and reads refuse them,
|
|
212
|
+
* so a caller that must know they exist asks here.
|
|
213
|
+
*/
|
|
214
|
+
async findSymlinks(under: string = '/'): Promise<Set<string>> {
|
|
215
|
+
const base = this.resolveUnderRoot(under);
|
|
216
|
+
return symlinksInDir(this.rootPath, under, base);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `{relPath: sha256Hex}` of every file under the directory `under`.
|
|
221
|
+
*
|
|
222
|
+
* Unchanged files — same size, mtime, and ctime since the last call —
|
|
223
|
+
* reuse their recorded hash instead of being re-read.
|
|
224
|
+
*/
|
|
225
|
+
async hashtree(under: string = '/'): Promise<Record<string, string>> {
|
|
226
|
+
const base = this.resolveUnderRoot(under);
|
|
227
|
+
const walkStartNs = _internals.nowNs();
|
|
228
|
+
// Null prototype so a file named `__proto__` (or `constructor`) is an
|
|
229
|
+
// ordinary own key instead of a silent prototype write / inherited read.
|
|
230
|
+
const out: Record<string, string> = Object.create(null);
|
|
231
|
+
for (const [rel, full] of await filenamesInDir(this.rootPath, under, base)) {
|
|
232
|
+
const sha = await this.hashViaCache(rel, full, walkStartNs);
|
|
233
|
+
if (sha !== null) out[rel] = sha;
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** One file's sha256; `null` when absent. Shares {@link hashtree}'s cache. */
|
|
239
|
+
async hashFile(relPath: string): Promise<string | null> {
|
|
240
|
+
const dest = this.resolveUnderRoot(relPath);
|
|
241
|
+
let st: BigIntStats;
|
|
242
|
+
try {
|
|
243
|
+
st = await fsp.lstat(dest, { bigint: true });
|
|
244
|
+
} catch (e) {
|
|
245
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
if (st.isSymbolicLink()) throw new FileStoreError(FileStoreError.IS_A_SYMLINK, relPath);
|
|
249
|
+
if (!st.isFile()) throw new FileStoreError(FileStoreError.NOT_A_FILE, relPath);
|
|
250
|
+
const rel = path.relative(this.rootPath, dest).split(path.sep).join('/');
|
|
251
|
+
return this.hashViaCache(rel, dest, _internals.nowNs());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Rename `src` to `dst`; an existing `dst` is refused. The banned store
|
|
256
|
+
* root as either end does nothing.
|
|
257
|
+
*/
|
|
258
|
+
async move(src: string, dst: string): Promise<void> {
|
|
259
|
+
const s = this.resolveUnderRoot(src);
|
|
260
|
+
const d = this.resolveUnderRoot(dst);
|
|
261
|
+
if (s === this.rootPath || d === this.rootPath) return;
|
|
262
|
+
// stat, not lstat: a dangling symlink at dst reads as absent and is
|
|
263
|
+
// atomically replaced by the rename, like any other rename target.
|
|
264
|
+
const dstExists = await fsp.stat(d).then(
|
|
265
|
+
() => true,
|
|
266
|
+
() => false,
|
|
267
|
+
);
|
|
268
|
+
if (dstExists) throw new FileStoreError(FileStoreError.MOVE_DESTINATION_EXISTS, dst);
|
|
269
|
+
await makeDirsBelowRoot(this.rootPath, path.dirname(d));
|
|
270
|
+
await fsp.rename(s, d);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Delete a file or subtree; absent — and the banned store root — do nothing. */
|
|
274
|
+
async remove(relPath: string): Promise<void> {
|
|
275
|
+
const dest = this.resolveUnderRoot(relPath);
|
|
276
|
+
if (dest === this.rootPath) return;
|
|
277
|
+
let st: BigIntStats;
|
|
278
|
+
try {
|
|
279
|
+
// lstat: a dangling symlink must still be unlinked.
|
|
280
|
+
st = await fsp.lstat(dest, { bigint: true });
|
|
281
|
+
} catch (e) {
|
|
282
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
283
|
+
throw e;
|
|
284
|
+
}
|
|
285
|
+
if (st.isDirectory()) {
|
|
286
|
+
await fsp.rm(dest, { recursive: true, force: true });
|
|
287
|
+
} else {
|
|
288
|
+
try {
|
|
289
|
+
await fsp.unlink(dest);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private resolveUnderRoot(relPath: string): string {
|
|
297
|
+
const norm = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
298
|
+
const parts = norm.split('/').filter((p) => p !== '' && p !== '.');
|
|
299
|
+
if (path.posix.isAbsolute(norm) || parts.includes('..')) {
|
|
300
|
+
throw new FileStoreError(FileStoreError.ESCAPES_ROOT, relPath);
|
|
301
|
+
}
|
|
302
|
+
return parts.length === 0 ? this.rootPath : path.join(this.rootPath, ...parts);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private requireUtf8(relPath: string, data: Uint8Array): void {
|
|
306
|
+
if (!this.decoder) return;
|
|
307
|
+
try {
|
|
308
|
+
this.decoder.decode(data);
|
|
309
|
+
} catch {
|
|
310
|
+
throw new FileStoreError(FileStoreError.NOT_UTF8, relPath);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private async hashViaCache(rel: string, full: string, walkStartNs: bigint): Promise<string | null> {
|
|
315
|
+
let st: BigIntStats;
|
|
316
|
+
try {
|
|
317
|
+
st = await fsp.lstat(full, { bigint: true });
|
|
318
|
+
} catch (e) {
|
|
319
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; // vanished since the walk: not in this snapshot
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
if (!st.isFile()) return null;
|
|
323
|
+
const cached = this.hashes.get(rel);
|
|
324
|
+
let sha: string;
|
|
325
|
+
if (cached !== undefined && unchangedSinceHashed(cached, st)) {
|
|
326
|
+
sha = cached.sha;
|
|
327
|
+
} else {
|
|
328
|
+
try {
|
|
329
|
+
sha = await _internals.hashFile(full);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
332
|
+
if (code === 'ENOENT' || e instanceof FileStoreError) return null;
|
|
333
|
+
// FreeBSD reports EMLINK rather than ELOOP for O_NOFOLLOW.
|
|
334
|
+
if (code === 'ELOOP' || code === 'EMLINK') return null;
|
|
335
|
+
throw e;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (oldEnoughToCache(st, walkStartNs)) {
|
|
339
|
+
this.hashes.set(rel, { mtimeNs: st.mtimeNs, ctimeNs: st.ctimeNs, size: st.size, sha });
|
|
340
|
+
}
|
|
341
|
+
return sha;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function platformSupported(): boolean {
|
|
346
|
+
return O_NOFOLLOW !== 0;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function makeDirAndAncestors(dir: string): Promise<void> {
|
|
350
|
+
const missing: string[] = [];
|
|
351
|
+
let current = dir;
|
|
352
|
+
for (;;) {
|
|
353
|
+
try {
|
|
354
|
+
await fsp.stat(current);
|
|
355
|
+
break;
|
|
356
|
+
} catch (e) {
|
|
357
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
358
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') throw e;
|
|
359
|
+
}
|
|
360
|
+
missing.push(current);
|
|
361
|
+
const parent = path.dirname(current);
|
|
362
|
+
if (parent === current) break;
|
|
363
|
+
current = parent;
|
|
364
|
+
}
|
|
365
|
+
for (const directory of missing.reverse()) {
|
|
366
|
+
try {
|
|
367
|
+
await fsp.mkdir(directory, { mode: OWNER_ONLY_DIR_MODE });
|
|
368
|
+
} catch (e) {
|
|
369
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function makeDirsBelowRoot(root: string, dir: string): Promise<void> {
|
|
375
|
+
// Never the root itself: only createRoot() makes it, so a write racing an
|
|
376
|
+
// rm -rf of the folder fails with ENOENT instead of re-creating it.
|
|
377
|
+
const below = path.relative(root, dir);
|
|
378
|
+
if (below === '') return;
|
|
379
|
+
let current = root;
|
|
380
|
+
for (const part of below.split(path.sep)) {
|
|
381
|
+
current = path.join(current, part);
|
|
382
|
+
try {
|
|
383
|
+
await fsp.mkdir(current, { mode: OWNER_ONLY_DIR_MODE });
|
|
384
|
+
} catch (e) {
|
|
385
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function replaceViaTemp(dest: string, data: Uint8Array, isExecutable: boolean): Promise<void> {
|
|
391
|
+
const mode = isExecutable ? OWNER_ONLY_EXEC_MODE : OWNER_ONLY_FILE_MODE;
|
|
392
|
+
const tmp = path.join(path.dirname(dest), `.fs-${crypto.randomBytes(8).toString('hex')}.tmp`);
|
|
393
|
+
let handle: FileHandle | undefined;
|
|
394
|
+
try {
|
|
395
|
+
handle = await fsp.open(tmp, C.O_WRONLY | C.O_CREAT | C.O_EXCL | O_NOFOLLOW, mode);
|
|
396
|
+
await handle.writeFile(data);
|
|
397
|
+
await handle.close();
|
|
398
|
+
handle = undefined;
|
|
399
|
+
await fsp.rename(tmp, dest);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
// Best-effort temp cleanup; never mask the original error.
|
|
402
|
+
if (handle) await handle.close().catch(() => {});
|
|
403
|
+
await fsp.unlink(tmp).catch(() => {});
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function openRegularFile(relPath: string, dest: string): Promise<FileHandle> {
|
|
409
|
+
// O_NONBLOCK: a FIFO fails the fstat check below instead of blocking the open.
|
|
410
|
+
let handle: FileHandle;
|
|
411
|
+
try {
|
|
412
|
+
handle = await fsp.open(dest, C.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
413
|
+
} catch (e) {
|
|
414
|
+
// FreeBSD reports EMLINK rather than ELOOP for O_NOFOLLOW.
|
|
415
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
416
|
+
if (code === 'ELOOP' || code === 'EMLINK') {
|
|
417
|
+
throw new FileStoreError(FileStoreError.IS_A_SYMLINK, relPath);
|
|
418
|
+
}
|
|
419
|
+
throw e;
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const st = await handle.stat();
|
|
423
|
+
if (!st.isFile()) throw new FileStoreError(FileStoreError.NOT_A_FILE, relPath);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
await handle.close().catch(() => {});
|
|
426
|
+
throw e;
|
|
427
|
+
}
|
|
428
|
+
return handle;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** sha256 of a file's contents, streamed — constant memory on any file size. */
|
|
432
|
+
async function hashFile(full: string): Promise<string> {
|
|
433
|
+
const digest = crypto.createHash('sha256');
|
|
434
|
+
const handle = await openRegularFile(path.basename(full), full);
|
|
435
|
+
const buf = new Uint8Array(1024 * 1024);
|
|
436
|
+
try {
|
|
437
|
+
for (;;) {
|
|
438
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length);
|
|
439
|
+
if (bytesRead === 0) break;
|
|
440
|
+
digest.update(buf.subarray(0, bytesRead));
|
|
441
|
+
}
|
|
442
|
+
} finally {
|
|
443
|
+
await handle.close();
|
|
444
|
+
}
|
|
445
|
+
return digest.digest('hex');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* `[rel, path]` for every regular file under the directory `base`; an absent
|
|
450
|
+
* `base` is empty, a present non-directory is refused. The walk never
|
|
451
|
+
* descends symlinked directories.
|
|
452
|
+
*/
|
|
453
|
+
async function filenamesInDir(root: string, under: string, base: string): Promise<[string, string][]> {
|
|
454
|
+
if (!(await requireDir(under, base))) return [];
|
|
455
|
+
const out: [string, string][] = [];
|
|
456
|
+
await walk(base, (full, entry) => {
|
|
457
|
+
if (entry.isFile()) out.push([path.relative(root, full).split(path.sep).join('/'), full]);
|
|
458
|
+
});
|
|
459
|
+
out.sort();
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function symlinksInDir(root: string, under: string, base: string): Promise<Set<string>> {
|
|
464
|
+
const relOf = (full: string) => path.relative(root, full).split(path.sep).join('/');
|
|
465
|
+
let st: BigIntStats;
|
|
466
|
+
try {
|
|
467
|
+
st = await fsp.lstat(base, { bigint: true });
|
|
468
|
+
} catch (e) {
|
|
469
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
470
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return new Set();
|
|
471
|
+
throw e;
|
|
472
|
+
}
|
|
473
|
+
if (st.isSymbolicLink()) return new Set([relOf(base)]);
|
|
474
|
+
if (!st.isDirectory()) throw new FileStoreError(FileStoreError.NOT_A_DIRECTORY, under);
|
|
475
|
+
const out = new Set<string>();
|
|
476
|
+
// Symlinks to directories are reported, never descended, like the rest.
|
|
477
|
+
await walk(base, (full, entry) => {
|
|
478
|
+
if (entry.isSymbolicLink()) out.add(relOf(full));
|
|
479
|
+
});
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** `false` when `base` is absent, refused when present but not a directory. */
|
|
484
|
+
async function requireDir(under: string, base: string): Promise<boolean> {
|
|
485
|
+
let st: BigIntStats;
|
|
486
|
+
try {
|
|
487
|
+
st = await fsp.lstat(base, { bigint: true });
|
|
488
|
+
} catch (e) {
|
|
489
|
+
// Absent — including "under a file" (ENOTDIR) — is an empty listing.
|
|
490
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
491
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return false;
|
|
492
|
+
throw e;
|
|
493
|
+
}
|
|
494
|
+
if (!st.isDirectory()) throw new FileStoreError(FileStoreError.NOT_A_DIRECTORY, under);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Visit every entry under `base` without descending symlinked directories. */
|
|
499
|
+
async function walk(base: string, visit: (full: string, entry: Dirent) => void): Promise<void> {
|
|
500
|
+
const stack: string[] = [base];
|
|
501
|
+
while (stack.length) {
|
|
502
|
+
const dir = stack.pop()!;
|
|
503
|
+
let entries: Dirent[];
|
|
504
|
+
try {
|
|
505
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
506
|
+
} catch (e) {
|
|
507
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue; // a listing is a snapshot, not a lock
|
|
508
|
+
throw e;
|
|
509
|
+
}
|
|
510
|
+
for (const entry of entries) {
|
|
511
|
+
const full = path.join(dir, entry.name);
|
|
512
|
+
visit(full, entry);
|
|
513
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) stack.push(full);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function unchangedSinceHashed(cached: Hashed, st: BigIntStats): boolean {
|
|
519
|
+
return st.mtimeNs === cached.mtimeNs && st.ctimeNs === cached.ctimeNs && st.size === cached.size;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function oldEnoughToCache(st: BigIntStats, walkStartNs: bigint): boolean {
|
|
523
|
+
const newestNs = st.mtimeNs > st.ctimeNs ? st.mtimeNs : st.ctimeNs;
|
|
524
|
+
return newestNs < walkStartNs - _internals.timestampTrustMarginNs;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Filesystems stamp times with coarse clocks, so a rewrite shortly after a
|
|
528
|
+
// hashed write can reuse the exact stamps. Files younger than the margin are
|
|
529
|
+
// simply re-hashed next walk.
|
|
530
|
+
const TIMESTAMP_TRUST_MARGIN_NS = 2_000_000_000n;
|
|
531
|
+
|
|
532
|
+
/** Test seam — the hasher, the trust margin, and the walk clock. @internal */
|
|
533
|
+
export const _internals = {
|
|
534
|
+
hashFile,
|
|
535
|
+
timestampTrustMarginNs: TIMESTAMP_TRUST_MARGIN_NS,
|
|
536
|
+
nowNs: (): bigint => BigInt(Date.now()) * 1_000_000n,
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
export const LocalFileStore = FileStore;
|
|
540
|
+
|
|
541
|
+
// Wire `Symbol.asyncDispose` at runtime when the host provides it — the
|
|
542
|
+
// repo's tsconfig targets ES2020 so the type-level `AsyncDisposable` lib is
|
|
543
|
+
// not available, but `await using` callers on newer engines still work.
|
|
544
|
+
const asyncDispose = (Symbol as { asyncDispose?: symbol }).asyncDispose;
|
|
545
|
+
if (asyncDispose) {
|
|
546
|
+
Object.defineProperty(FileStore.prototype, asyncDispose, {
|
|
547
|
+
value: FileStore.prototype.dispose,
|
|
548
|
+
configurable: true,
|
|
549
|
+
writable: true,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AnthropicError } from '../core/error';
|
|
2
|
+
import type * as node from './node';
|
|
3
|
+
|
|
4
|
+
/** Substituted for `./node` by the package.json `browser` field; every property access throws. */
|
|
5
|
+
function unavailable<T extends object>(module: string): T {
|
|
6
|
+
return new Proxy({} as T, {
|
|
7
|
+
get(_target, property) {
|
|
8
|
+
if (typeof property === 'symbol') return undefined;
|
|
9
|
+
throw new AnthropicError(
|
|
10
|
+
`\`${module}.${property}\` is not available in this environment; it needs a Node.js-compatible runtime`,
|
|
11
|
+
);
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const child_process: typeof node.child_process = unavailable('child_process');
|
|
17
|
+
export const crypto: typeof node.crypto = unavailable('crypto');
|
|
18
|
+
export const fs: typeof node.fs = unavailable('fs');
|
|
19
|
+
export const os: typeof node.os = unavailable('os');
|
|
20
|
+
export const path: typeof node.path = unavailable('path');
|
|
21
|
+
export const stream: typeof node.stream = unavailable('stream');
|
|
22
|
+
export const util: typeof node.util = unavailable('util');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one module under `src/` that may import Node built-ins (eslint enforces this).
|
|
3
|
+
* The package.json `browser` field swaps it for `./node.browser`, so only touch its
|
|
4
|
+
* exports on code paths that run on Node-compatible runtimes.
|
|
5
|
+
*/
|
|
6
|
+
import * as child_process from 'node:child_process';
|
|
7
|
+
import * as crypto from 'node:crypto';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as stream from 'node:stream';
|
|
12
|
+
import * as util from 'node:util';
|
|
13
|
+
|
|
14
|
+
export { child_process, crypto, fs, os, path, stream, util };
|
package/src/internal/parse.ts
CHANGED
|
@@ -28,7 +28,7 @@ export async function defaultParseResponse<T>(
|
|
|
28
28
|
// Note: there is an invariant here that isn't represented in the type system
|
|
29
29
|
// that if you set `stream: true` the response type must also be `Stream<T>`
|
|
30
30
|
|
|
31
|
-
return Stream.fromSSEResponse(response, props.controller) as any;
|
|
31
|
+
return Stream.fromSSEResponse(response, props.controller, client) as any;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// fetch refuses to read the body when the status code is 204.
|
|
@@ -51,7 +51,7 @@ export async function defaultParseResponse<T>(
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const json = await response.json();
|
|
54
|
-
return
|
|
54
|
+
return addResponseIDs(json as T, response);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const text = await response.text();
|
|
@@ -80,16 +80,16 @@ export async function defaultParseResponse<T>(
|
|
|
80
80
|
|
|
81
81
|
export type WithRequestID<T> =
|
|
82
82
|
T extends Array<any> | Response | AbstractPage<any> ? T
|
|
83
|
-
: T extends Record<string, any> ? T & { _request_id?: string | null }
|
|
83
|
+
: T extends Record<string, any> ? T & { _request_id?: string | null; _workspace_id?: string | null }
|
|
84
84
|
: T;
|
|
85
85
|
|
|
86
|
-
export function
|
|
86
|
+
export function addResponseIDs<T>(value: T, response: Response): WithRequestID<T> {
|
|
87
87
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
88
88
|
return value as WithRequestID<T>;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
return Object.
|
|
92
|
-
value: response.headers.get('request-id'),
|
|
93
|
-
enumerable: false,
|
|
91
|
+
return Object.defineProperties(value, {
|
|
92
|
+
_request_id: { value: response.headers.get('request-id'), enumerable: false },
|
|
93
|
+
_workspace_id: { value: response.headers.get('anthropic-workspace-id'), enumerable: false },
|
|
94
94
|
}) as WithRequestID<T>;
|
|
95
95
|
}
|
package/src/internal/uploads.ts
CHANGED
|
@@ -137,14 +137,11 @@ export const createForm = async <T = Record<string, unknown>>(
|
|
|
137
137
|
return form;
|
|
138
138
|
};
|
|
139
139
|
|
|
140
|
-
//
|
|
141
|
-
// but they both inherit from Blob and have a `name` property at runtime.
|
|
142
|
-
const isNamedBlob = (value: unknown): value is Blob => value instanceof Blob && 'name' in value;
|
|
143
|
-
|
|
140
|
+
// Blob, not File: bare Blobs and Bun.file() results don't inherit from File.
|
|
144
141
|
const isUploadable = (value: unknown) =>
|
|
145
142
|
typeof value === 'object' &&
|
|
146
143
|
value !== null &&
|
|
147
|
-
(value instanceof Response || isAsyncIterable(value) ||
|
|
144
|
+
(value instanceof Response || isAsyncIterable(value) || value instanceof Blob);
|
|
148
145
|
|
|
149
146
|
const hasUploadableValue = (value: unknown): boolean => {
|
|
150
147
|
if (isUploadable(value)) return true;
|
|
@@ -186,10 +183,16 @@ const addFormValue = async (
|
|
|
186
183
|
key,
|
|
187
184
|
makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value, stripFilenames)),
|
|
188
185
|
);
|
|
189
|
-
} else if (
|
|
190
|
-
form.append(key, makeFile([value], getName(value, stripFilenames), { type: value.type }));
|
|
186
|
+
} else if (value instanceof Blob) {
|
|
187
|
+
form.append(key, makeFile([value], getName(value, stripFilenames) || undefined, { type: value.type }));
|
|
191
188
|
} else if (Array.isArray(value)) {
|
|
192
189
|
await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry, stripFilenames)));
|
|
190
|
+
} else if (typeof (value as any).then === 'function') {
|
|
191
|
+
throw new TypeError(`Received a Promise for "${key}"; await it first, e.g. \`await toFile(...)\``);
|
|
192
|
+
} else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
193
|
+
throw new TypeError(
|
|
194
|
+
`Received ${value.constructor.name} for "${key}"; to upload raw bytes, wrap them with \`await toFile(bytes, 'filename')\``,
|
|
195
|
+
);
|
|
193
196
|
} else if (typeof value === 'object') {
|
|
194
197
|
await Promise.all(
|
|
195
198
|
Object.entries(value).map(([name, prop]) =>
|
|
@@ -110,3 +110,10 @@ export const pop = <T extends Record<string, any>, K extends string>(obj: T, key
|
|
|
110
110
|
delete obj[key];
|
|
111
111
|
return value;
|
|
112
112
|
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Compile-time exhaustiveness check: passing a value here only type-checks once every
|
|
116
|
+
* member of its union has been handled. Does nothing at runtime, so unknown values from a
|
|
117
|
+
* newer API version fall through instead of throwing.
|
|
118
|
+
*/
|
|
119
|
+
export function checkNever(_value: never): void {}
|