@adukilabs/sdk 0.1.1 → 0.1.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.
- package/README.md +114 -2
- package/dist/api/api.d.ts +3 -0
- package/dist/api/api.js +7 -3
- package/dist/api/id.d.ts +75 -0
- package/dist/api/id.js +184 -0
- package/dist/api/index.d.ts +14 -1
- package/dist/api/index.js +15 -5
- package/dist/api/model.d.ts +250 -0
- package/dist/api/space.d.ts +147 -0
- package/dist/api/space.js +218 -0
- package/dist/api/store.d.ts +181 -0
- package/dist/api/store.js +352 -0
- package/dist/api/tenant.d.ts +319 -0
- package/dist/api/tenant.js +429 -0
- package/dist/base64.d.ts +11 -0
- package/dist/base64.js +40 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.js +22 -0
- package/dist/dpop.d.ts +82 -0
- package/dist/dpop.js +156 -0
- package/dist/errors.d.ts +13 -1
- package/dist/errors.js +17 -1
- package/dist/space/model.d.ts +147 -0
- package/dist/store/blake3.d.ts +4 -0
- package/dist/store/blake3.js +130 -0
- package/dist/store/cdc.d.ts +12 -0
- package/dist/store/cdc.js +127 -0
- package/dist/store/chunks.d.ts +23 -0
- package/dist/store/chunks.js +63 -0
- package/dist/store/model.d.ts +202 -0
- package/dist/store/model.js +38 -0
- package/package.json +6 -3
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { Content, Manifest } from '../store/chunks.js';
|
|
2
|
+
import type { Begun, Derivative, FileEntry, Folder, Listing, Quota, Version, VersionStatus } from '../store/model.js';
|
|
3
|
+
/** Store's metadata API. */
|
|
4
|
+
export declare const STORE = "https://store.aduki.pro/v1";
|
|
5
|
+
/** Store's content API (chunks, downloads). */
|
|
6
|
+
export declare const FILES = "https://files.aduki.pro/v1";
|
|
7
|
+
/** An authenticated fetch for one audience (`IdClient`). */
|
|
8
|
+
export interface AudienceFetch {
|
|
9
|
+
fetch(audience: string, input: Request | string | URL, init?: RequestInit): Promise<Response>;
|
|
10
|
+
}
|
|
11
|
+
export interface StoreApiOptions {
|
|
12
|
+
/** the metadata root with `/v1` (default `https://store.aduki.pro/v1`) */
|
|
13
|
+
root?: string | undefined;
|
|
14
|
+
/** the content root with `/v1` (default `https://files.aduki.pro/v1`) */
|
|
15
|
+
files?: string | undefined;
|
|
16
|
+
/** waits `ms` milliseconds before retrying a busy chunk (default a timer; tests pass a fake) */
|
|
17
|
+
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
18
|
+
}
|
|
19
|
+
/** What `updateFolder` changes: `parent: null` moves to the drive root. */
|
|
20
|
+
export interface FolderChange {
|
|
21
|
+
name?: string;
|
|
22
|
+
parent?: string | null;
|
|
23
|
+
trashed?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** What `updateFile` changes: `folder: null` moves to the drive root. */
|
|
26
|
+
export interface FileChange {
|
|
27
|
+
name?: string;
|
|
28
|
+
folder?: string | null;
|
|
29
|
+
trashed?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** Client metadata of a version: modification time (a Date or milliseconds) and the executable bit. */
|
|
32
|
+
export interface VersionAttrs {
|
|
33
|
+
mtime: Date | number;
|
|
34
|
+
exec: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Progress of an upload: hashing the content, then sending the missing chunks. */
|
|
37
|
+
export interface UploadProgress {
|
|
38
|
+
phase: 'hash' | 'send';
|
|
39
|
+
done: number;
|
|
40
|
+
total: number;
|
|
41
|
+
}
|
|
42
|
+
export interface StoreUploadOptions {
|
|
43
|
+
/** an existing file to add a version to; without it a new file is created */
|
|
44
|
+
file?: string;
|
|
45
|
+
/** the new file's name (required without `file`; default the name of a `File`) */
|
|
46
|
+
name?: string;
|
|
47
|
+
/** the new file's folder (default the drive root) */
|
|
48
|
+
folder?: string | null;
|
|
49
|
+
/** the new file's type (default the Blob's type) */
|
|
50
|
+
mime?: string;
|
|
51
|
+
/** the version this edit started from (default the file's current version, when adding to `file`) */
|
|
52
|
+
base?: string;
|
|
53
|
+
/** a UUID: the same key (and content) resumes the same upload (default a new one) */
|
|
54
|
+
key?: string;
|
|
55
|
+
attrs?: VersionAttrs;
|
|
56
|
+
/** a manifest computed before (`manifest(content)`), to skip hashing again */
|
|
57
|
+
manifest?: Manifest;
|
|
58
|
+
progress?: (progress: UploadProgress) => void;
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
}
|
|
61
|
+
/** What an upload made: the file, the version and its state (`pending` while scanned, or `ready`). */
|
|
62
|
+
export interface StoreUploaded {
|
|
63
|
+
file: FileEntry;
|
|
64
|
+
version: string;
|
|
65
|
+
state: string;
|
|
66
|
+
/** bytes sent (0 when the server already held every chunk) */
|
|
67
|
+
sent: number;
|
|
68
|
+
}
|
|
69
|
+
/** A download: the current version or `version`; `range` asks for bytes `start` to `end` (inclusive); `etag` for a `304`. */
|
|
70
|
+
export interface StoreDownload {
|
|
71
|
+
version?: string;
|
|
72
|
+
range?: {
|
|
73
|
+
start: number;
|
|
74
|
+
end?: number;
|
|
75
|
+
};
|
|
76
|
+
etag?: string;
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
}
|
|
79
|
+
type Query = Record<string, string | number | boolean | undefined>;
|
|
80
|
+
/** The workspace drive (see the module notes for what a program may not do). */
|
|
81
|
+
export declare class StoreApi {
|
|
82
|
+
#private;
|
|
83
|
+
readonly root: string;
|
|
84
|
+
readonly files: string;
|
|
85
|
+
constructor(session: AudienceFetch, options?: StoreApiOptions);
|
|
86
|
+
/** The workspace drive's numbers: `owner` (the workspace), `cap`, `per`, `used` (`GET /v1/quota`). */
|
|
87
|
+
quota(): Promise<Quota>;
|
|
88
|
+
/** The live children of folder `parent`, or of the drive root; with `trashed`, the trash (`GET /v1/folders`). */
|
|
89
|
+
list(options?: {
|
|
90
|
+
parent?: string;
|
|
91
|
+
trashed?: boolean;
|
|
92
|
+
}): Promise<Listing>;
|
|
93
|
+
/** Folder `folder` (`GET /v1/folders/{id}`); one outside the workspace drive is `404`. */
|
|
94
|
+
folder(folder: string): Promise<Folder>;
|
|
95
|
+
/** File `file` (`GET /v1/files/{id}`). */
|
|
96
|
+
file(file: string): Promise<FileEntry>;
|
|
97
|
+
/** Creates a folder in `parent` (default the drive root) (`POST /v1/folders`, `store.write`); a name in use is 409. */
|
|
98
|
+
createFolder(name: string, options?: {
|
|
99
|
+
parent?: string | null;
|
|
100
|
+
}): Promise<Folder>;
|
|
101
|
+
/** Creates an empty file to upload a version into (`POST /v1/files`). `upload` does this for you. */
|
|
102
|
+
createFile(name: string, options?: {
|
|
103
|
+
folder?: string | null;
|
|
104
|
+
mime?: string;
|
|
105
|
+
}): Promise<FileEntry>;
|
|
106
|
+
/** Renames, moves (`parent: null` is the drive root), trashes or restores a folder (`PATCH /v1/folders/{id}`). */
|
|
107
|
+
updateFolder(folder: string, change: FolderChange): Promise<Folder>;
|
|
108
|
+
/** Renames, moves (`folder: null` is the drive root), trashes or restores a file (`PATCH /v1/files/{id}`). */
|
|
109
|
+
updateFile(file: string, change: FileChange): Promise<FileEntry>;
|
|
110
|
+
/** Moves a folder and everything in it to the trash (`DELETE /v1/folders/{id}`). */
|
|
111
|
+
trashFolder(folder: string): Promise<Folder>;
|
|
112
|
+
/** Moves a file to the trash (`DELETE /v1/files/{id}`). */
|
|
113
|
+
trashFile(file: string): Promise<FileEntry>;
|
|
114
|
+
/** Takes a folder out of the trash. */
|
|
115
|
+
restoreFolder(folder: string): Promise<Folder>;
|
|
116
|
+
/** Takes a file out of the trash. */
|
|
117
|
+
restoreFile(file: string): Promise<FileEntry>;
|
|
118
|
+
/** The versions of a file, newest first (`GET /v1/files/{id}/versions`). */
|
|
119
|
+
versions(file: string): Promise<Version[]>;
|
|
120
|
+
/** A version and the chunk positions still missing (`GET /v1/versions/{id}`). */
|
|
121
|
+
version(version: string): Promise<VersionStatus>;
|
|
122
|
+
/** Begins a version of `file` with its manifest (`POST /v1/files/{id}/versions`); `key` (a UUID) makes it idempotent. */
|
|
123
|
+
begin(file: string, plan: Manifest, options?: {
|
|
124
|
+
base?: string;
|
|
125
|
+
key?: string;
|
|
126
|
+
attrs?: VersionAttrs;
|
|
127
|
+
}): Promise<Begun>;
|
|
128
|
+
/**
|
|
129
|
+
* Sends one chunk of an open version (`PUT /v1/chunks/{owner}/{hash}` on
|
|
130
|
+
* the content host): true when stored, false when the drive already held
|
|
131
|
+
* it. A chunk being reclaimed (`409 chunk.busy`) is retried after the
|
|
132
|
+
* server's wait, up to three times.
|
|
133
|
+
*/
|
|
134
|
+
putChunk(owner: string, version: string, hash: string, bytes: Uint8Array | Blob, signal?: AbortSignal): Promise<boolean>;
|
|
135
|
+
/** Commits a version (`POST /v1/files/{id}/commits`); chunks still missing are `409` with `details.missing`. */
|
|
136
|
+
commit(file: string, version: string): Promise<{
|
|
137
|
+
version: string;
|
|
138
|
+
state: string;
|
|
139
|
+
}>;
|
|
140
|
+
/** Aborts an uncommitted version and frees its reservation (`DELETE /v1/files/{id}/versions/{version}`). */
|
|
141
|
+
abort(file: string, version: string): Promise<{
|
|
142
|
+
released: number;
|
|
143
|
+
}>;
|
|
144
|
+
/**
|
|
145
|
+
* Uploads `content` as a new file (`name`, `folder`) or as a new version of
|
|
146
|
+
* `file`: hashes it, begins the version, sends the missing chunks and
|
|
147
|
+
* commits. Run again with the same `key` to resume: only the chunks the
|
|
148
|
+
* server still lacks are sent. The version is `pending` until scanned.
|
|
149
|
+
*/
|
|
150
|
+
upload(content: Content, options?: StoreUploadOptions): Promise<StoreUploaded>;
|
|
151
|
+
/**
|
|
152
|
+
* The content of a file as a Response (`GET /v1/files/{id}/content` on the
|
|
153
|
+
* content host): `200`, `206` for a range, `304` for an unchanged `etag`;
|
|
154
|
+
* `ETag`, `Content-Type`, `Content-Range` headers. A failure throws (`416`
|
|
155
|
+
* for a range past the end).
|
|
156
|
+
*/
|
|
157
|
+
content(file: string, options?: StoreDownload): Promise<Response>;
|
|
158
|
+
/** The content of a file as a Blob (the whole file, or `range`). */
|
|
159
|
+
download(file: string, options?: StoreDownload): Promise<Blob>;
|
|
160
|
+
/** The renditions (thumbnails, posters, previews) of a version (`GET /v1/versions/{id}/media`). */
|
|
161
|
+
media(version: string): Promise<Derivative[]>;
|
|
162
|
+
/** The bytes of a ready rendition (`GET /v1/derivatives/{id}/content` on the content host), or a `range` of them. */
|
|
163
|
+
derivative(derivative: string, options?: {
|
|
164
|
+
range?: {
|
|
165
|
+
start: number;
|
|
166
|
+
end?: number;
|
|
167
|
+
};
|
|
168
|
+
signal?: AbortSignal;
|
|
169
|
+
}): Promise<Response>;
|
|
170
|
+
/**
|
|
171
|
+
* Any metadata route under `/v1` (`path` without it): returns the
|
|
172
|
+
* envelope's `data`; a refusal throws the typed error of `errors.refusal`
|
|
173
|
+
* (`ClientRefusedError` for a route that is not for programs).
|
|
174
|
+
*/
|
|
175
|
+
call<T = any>(method: string, path: string, options?: {
|
|
176
|
+
query?: Query;
|
|
177
|
+
body?: unknown;
|
|
178
|
+
signal?: AbortSignal;
|
|
179
|
+
}): Promise<T>;
|
|
180
|
+
}
|
|
181
|
+
export {};
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// The workspace drive on Aduki Store for a program (machine access): the
|
|
2
|
+
// metadata API on `store.aduki.pro/v1` and the content API on
|
|
3
|
+
// `files.aduki.pro/v1`, with the DPoP-bound service-client token of
|
|
4
|
+
// `IdClient` (audience `store`).
|
|
5
|
+
//
|
|
6
|
+
// What Store gives a service client (its docs, "Programs of a workspace"):
|
|
7
|
+
// the workspace drive only, outside the Space folder, at the level of the
|
|
8
|
+
// client's bits: `store.read` reads, `store.write` also creates, renames,
|
|
9
|
+
// moves, trashes, restores and uploads. Never a drive of its own, never a
|
|
10
|
+
// member's or a personal drive (their ids answer `404`). Shares, public
|
|
11
|
+
// links, devices, reports, search, tickets and the change feed are for people:
|
|
12
|
+
// `403 access.denied` with reason `client` (`ClientRefusedError`), so this
|
|
13
|
+
// class has no calls for them.
|
|
14
|
+
//
|
|
15
|
+
// Uploads use the same chunking and hashing as the web Store module
|
|
16
|
+
// (FastCDC 2020 cut points, BLAKE3 chunk and manifest hashes): begin a version
|
|
17
|
+
// with the manifest, PUT the chunks the server lacks, commit; the same `key`
|
|
18
|
+
// resumes an interrupted upload.
|
|
19
|
+
import { failure, parse, rejection } from '../envelope.js';
|
|
20
|
+
import { NetworkError, ProtocolError } from '../errors.js';
|
|
21
|
+
import { url } from '../http.js';
|
|
22
|
+
import { blob, manifest } from '../store/chunks.js';
|
|
23
|
+
import * as model from '../store/model.js';
|
|
24
|
+
/** Store's metadata API. */
|
|
25
|
+
export const STORE = 'https://store.aduki.pro/v1';
|
|
26
|
+
/** Store's content API (chunks, downloads). */
|
|
27
|
+
export const FILES = 'https://files.aduki.pro/v1';
|
|
28
|
+
const HEX = /^[0-9a-f]{16}$/;
|
|
29
|
+
/** The workspace drive (see the module notes for what a program may not do). */
|
|
30
|
+
export class StoreApi {
|
|
31
|
+
root;
|
|
32
|
+
files;
|
|
33
|
+
#fetch;
|
|
34
|
+
#sleep;
|
|
35
|
+
constructor(session, options = {}) {
|
|
36
|
+
this.#fetch = (input, init) => session.fetch('store', input, init);
|
|
37
|
+
this.root = (options.root ?? STORE).replace(/\/+$/, '');
|
|
38
|
+
this.files = (options.files ?? FILES).replace(/\/+$/, '');
|
|
39
|
+
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
40
|
+
}
|
|
41
|
+
/** The workspace drive's numbers: `owner` (the workspace), `cap`, `per`, `used` (`GET /v1/quota`). */
|
|
42
|
+
async quota() {
|
|
43
|
+
return (await this.call('GET', 'quota'));
|
|
44
|
+
}
|
|
45
|
+
/** The live children of folder `parent`, or of the drive root; with `trashed`, the trash (`GET /v1/folders`). */
|
|
46
|
+
async list(options = {}) {
|
|
47
|
+
const query = { parent: options.parent && id(options.parent, 'parent'), trashed: options.trashed ? true : undefined };
|
|
48
|
+
return model.listing(await this.call('GET', 'folders', { query }));
|
|
49
|
+
}
|
|
50
|
+
/** Folder `folder` (`GET /v1/folders/{id}`); one outside the workspace drive is `404`. */
|
|
51
|
+
async folder(folder) {
|
|
52
|
+
return model.folder(await this.call('GET', `folders/${id(folder, 'folder')}`));
|
|
53
|
+
}
|
|
54
|
+
/** File `file` (`GET /v1/files/{id}`). */
|
|
55
|
+
async file(file) {
|
|
56
|
+
return model.file(await this.call('GET', `files/${id(file, 'file')}`));
|
|
57
|
+
}
|
|
58
|
+
/** Creates a folder in `parent` (default the drive root) (`POST /v1/folders`, `store.write`); a name in use is 409. */
|
|
59
|
+
async createFolder(name, options = {}) {
|
|
60
|
+
const body = { name };
|
|
61
|
+
if (options.parent)
|
|
62
|
+
body.parent = id(options.parent, 'parent');
|
|
63
|
+
return model.folder(await this.call('POST', 'folders', { body }));
|
|
64
|
+
}
|
|
65
|
+
/** Creates an empty file to upload a version into (`POST /v1/files`). `upload` does this for you. */
|
|
66
|
+
async createFile(name, options = {}) {
|
|
67
|
+
const body = { name };
|
|
68
|
+
if (options.folder)
|
|
69
|
+
body.folder = id(options.folder, 'folder');
|
|
70
|
+
if (options.mime)
|
|
71
|
+
body.mime = options.mime;
|
|
72
|
+
return model.file(await this.call('POST', 'files', { body }));
|
|
73
|
+
}
|
|
74
|
+
/** Renames, moves (`parent: null` is the drive root), trashes or restores a folder (`PATCH /v1/folders/{id}`). */
|
|
75
|
+
async updateFolder(folder, change) {
|
|
76
|
+
const body = {};
|
|
77
|
+
if (change.name !== undefined)
|
|
78
|
+
body.name = change.name;
|
|
79
|
+
if (change.parent !== undefined)
|
|
80
|
+
body.parent = change.parent === null ? null : id(change.parent, 'parent');
|
|
81
|
+
if (change.trashed !== undefined)
|
|
82
|
+
body.trashed = change.trashed;
|
|
83
|
+
return model.folder(await this.call('PATCH', `folders/${id(folder, 'folder')}`, { body }));
|
|
84
|
+
}
|
|
85
|
+
/** Renames, moves (`folder: null` is the drive root), trashes or restores a file (`PATCH /v1/files/{id}`). */
|
|
86
|
+
async updateFile(file, change) {
|
|
87
|
+
const body = {};
|
|
88
|
+
if (change.name !== undefined)
|
|
89
|
+
body.name = change.name;
|
|
90
|
+
if (change.folder !== undefined)
|
|
91
|
+
body.folder = change.folder === null ? null : id(change.folder, 'folder');
|
|
92
|
+
if (change.trashed !== undefined)
|
|
93
|
+
body.trashed = change.trashed;
|
|
94
|
+
return model.file(await this.call('PATCH', `files/${id(file, 'file')}`, { body }));
|
|
95
|
+
}
|
|
96
|
+
/** Moves a folder and everything in it to the trash (`DELETE /v1/folders/{id}`). */
|
|
97
|
+
async trashFolder(folder) {
|
|
98
|
+
return model.folder(await this.call('DELETE', `folders/${id(folder, 'folder')}`));
|
|
99
|
+
}
|
|
100
|
+
/** Moves a file to the trash (`DELETE /v1/files/{id}`). */
|
|
101
|
+
async trashFile(file) {
|
|
102
|
+
return model.file(await this.call('DELETE', `files/${id(file, 'file')}`));
|
|
103
|
+
}
|
|
104
|
+
/** Takes a folder out of the trash. */
|
|
105
|
+
restoreFolder(folder) {
|
|
106
|
+
return this.updateFolder(folder, { trashed: false });
|
|
107
|
+
}
|
|
108
|
+
/** Takes a file out of the trash. */
|
|
109
|
+
restoreFile(file) {
|
|
110
|
+
return this.updateFile(file, { trashed: false });
|
|
111
|
+
}
|
|
112
|
+
// ---- versions and uploads ------------------------------------------
|
|
113
|
+
/** The versions of a file, newest first (`GET /v1/files/{id}/versions`). */
|
|
114
|
+
async versions(file) {
|
|
115
|
+
const list = await this.call('GET', `files/${id(file, 'file')}/versions`);
|
|
116
|
+
return (Array.isArray(list) ? list : []).map(model.version);
|
|
117
|
+
}
|
|
118
|
+
/** A version and the chunk positions still missing (`GET /v1/versions/{id}`). */
|
|
119
|
+
async version(version) {
|
|
120
|
+
return model.status(await this.call('GET', `versions/${id(version, 'version')}`));
|
|
121
|
+
}
|
|
122
|
+
/** Begins a version of `file` with its manifest (`POST /v1/files/{id}/versions`); `key` (a UUID) makes it idempotent. */
|
|
123
|
+
async begin(file, plan, options = {}) {
|
|
124
|
+
const fields = {
|
|
125
|
+
size: plan.size,
|
|
126
|
+
hash: plan.hash,
|
|
127
|
+
chunks: plan.chunks.map((c) => ({ hash: c.hash, size: c.size })),
|
|
128
|
+
};
|
|
129
|
+
if (options.base)
|
|
130
|
+
fields.base = id(options.base, 'base');
|
|
131
|
+
if (options.key)
|
|
132
|
+
fields.idempotency = options.key;
|
|
133
|
+
let body = JSON.stringify(fields);
|
|
134
|
+
if (options.attrs) {
|
|
135
|
+
// mtime_ns passes 2^53: written as an exact integer, not through a double
|
|
136
|
+
const ms = options.attrs.mtime instanceof Date ? options.attrs.mtime.getTime() : options.attrs.mtime;
|
|
137
|
+
if (!Number.isFinite(ms))
|
|
138
|
+
throw new TypeError('attrs.mtime is not a time');
|
|
139
|
+
const ns = (BigInt(Math.round(ms)) * 1000000n).toString();
|
|
140
|
+
body = `${body.slice(0, -1)},"attrs":{"mtime_ns":${ns},"exec":${options.attrs.exec ? 'true' : 'false'}}}`;
|
|
141
|
+
}
|
|
142
|
+
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
143
|
+
if (options.key)
|
|
144
|
+
headers['Idempotency-Key'] = options.key;
|
|
145
|
+
const data = await this.#raw(url(this.root, `files/${id(file, 'file')}/versions`), { method: 'POST', headers, body }, 'Begin');
|
|
146
|
+
return { version: data.version, state: data.state, missing: data.missing ?? [], expires: data.expires ?? null, reserved: data.reserved ?? 0 };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Sends one chunk of an open version (`PUT /v1/chunks/{owner}/{hash}` on
|
|
150
|
+
* the content host): true when stored, false when the drive already held
|
|
151
|
+
* it. A chunk being reclaimed (`409 chunk.busy`) is retried after the
|
|
152
|
+
* server's wait, up to three times.
|
|
153
|
+
*/
|
|
154
|
+
async putChunk(owner, version, hash, bytes, signal) {
|
|
155
|
+
const target = url(this.files, `chunks/${id(owner, 'owner')}/${digest(hash)}`);
|
|
156
|
+
for (let attempt = 0;; attempt++) {
|
|
157
|
+
const init = {
|
|
158
|
+
method: 'PUT',
|
|
159
|
+
headers: { 'X-Version': id(version, 'version'), 'Content-Type': 'application/octet-stream' },
|
|
160
|
+
body: bytes instanceof Uint8Array ? new Uint8Array(bytes) : bytes,
|
|
161
|
+
};
|
|
162
|
+
if (signal)
|
|
163
|
+
init.signal = signal;
|
|
164
|
+
const response = await this.#fetch(target, init);
|
|
165
|
+
if (response.status === 201 || response.status === 204) {
|
|
166
|
+
await response.arrayBuffer().catch(() => { });
|
|
167
|
+
return response.status === 201;
|
|
168
|
+
}
|
|
169
|
+
const text = await response.text();
|
|
170
|
+
const error = failure(text);
|
|
171
|
+
if (response.status === 409 && error?.kind === 'chunk.busy' && attempt < 3) {
|
|
172
|
+
const wait = Number(error.details?.retry) || 5;
|
|
173
|
+
await this.#sleep(wait * 1000);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
throw rejection(response, text, 'Chunk');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Commits a version (`POST /v1/files/{id}/commits`); chunks still missing are `409` with `details.missing`. */
|
|
180
|
+
async commit(file, version) {
|
|
181
|
+
return (await this.call('POST', `files/${id(file, 'file')}/commits`, { body: { version: id(version, 'version') } }));
|
|
182
|
+
}
|
|
183
|
+
/** Aborts an uncommitted version and frees its reservation (`DELETE /v1/files/{id}/versions/{version}`). */
|
|
184
|
+
async abort(file, version) {
|
|
185
|
+
return (await this.call('DELETE', `files/${id(file, 'file')}/versions/${id(version, 'version')}`));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Uploads `content` as a new file (`name`, `folder`) or as a new version of
|
|
189
|
+
* `file`: hashes it, begins the version, sends the missing chunks and
|
|
190
|
+
* commits. Run again with the same `key` to resume: only the chunks the
|
|
191
|
+
* server still lacks are sent. The version is `pending` until scanned.
|
|
192
|
+
*/
|
|
193
|
+
async upload(content, options = {}) {
|
|
194
|
+
const source = blob(content);
|
|
195
|
+
let file;
|
|
196
|
+
let base = options.base;
|
|
197
|
+
if (options.file) {
|
|
198
|
+
file = await this.file(options.file);
|
|
199
|
+
base ??= file.version ?? undefined;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const named = source.name;
|
|
203
|
+
const name = options.name ?? (typeof named === 'string' ? named : '');
|
|
204
|
+
if (!name)
|
|
205
|
+
throw new TypeError('upload needs a name or a file');
|
|
206
|
+
const place = { folder: options.folder ?? null };
|
|
207
|
+
const mime = options.mime ?? source.type;
|
|
208
|
+
if (mime)
|
|
209
|
+
place.mime = mime;
|
|
210
|
+
file = await this.createFile(name, place);
|
|
211
|
+
}
|
|
212
|
+
const owner = file.owner;
|
|
213
|
+
if (!owner)
|
|
214
|
+
throw new ProtocolError('The file has no owner');
|
|
215
|
+
const plan = options.manifest ?? (await manifest(source, (done) => options.progress?.({ phase: 'hash', done, total: source.size })));
|
|
216
|
+
const begin = { key: options.key ?? crypto.randomUUID() };
|
|
217
|
+
if (base)
|
|
218
|
+
begin.base = base;
|
|
219
|
+
if (options.attrs)
|
|
220
|
+
begin.attrs = options.attrs;
|
|
221
|
+
const begun = await this.begin(file.id, plan, begin);
|
|
222
|
+
if (begun.state === 'fast')
|
|
223
|
+
return { file, version: begun.version, state: 'ready', sent: 0 };
|
|
224
|
+
let sent = 0;
|
|
225
|
+
const total = begun.missing.reduce((n, i) => n + (plan.chunks[i]?.size ?? 0), 0);
|
|
226
|
+
const push = async (missing) => {
|
|
227
|
+
for (const index of missing) {
|
|
228
|
+
const chunk = plan.chunks[index];
|
|
229
|
+
if (!chunk)
|
|
230
|
+
throw new ProtocolError(`The server asked for chunk ${index}, which the manifest does not have`);
|
|
231
|
+
options.signal?.throwIfAborted();
|
|
232
|
+
await this.putChunk(owner, begun.version, chunk.hash, source.slice(chunk.offset, chunk.offset + chunk.size), options.signal);
|
|
233
|
+
sent += chunk.size;
|
|
234
|
+
options.progress?.({ phase: 'send', done: sent, total: Math.max(total, sent) });
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
await push(begun.missing);
|
|
238
|
+
for (let round = 0;; round++) {
|
|
239
|
+
try {
|
|
240
|
+
const done = await this.commit(file.id, begun.version);
|
|
241
|
+
return { file, version: done.version, state: done.state, sent };
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
const missing = error?.details;
|
|
245
|
+
if (round < 2 && error instanceof NetworkError && error.status === 409 && Array.isArray(missing?.missing)) {
|
|
246
|
+
await push(missing.missing);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ---- downloads and renditions -------------------------------------
|
|
254
|
+
/**
|
|
255
|
+
* The content of a file as a Response (`GET /v1/files/{id}/content` on the
|
|
256
|
+
* content host): `200`, `206` for a range, `304` for an unchanged `etag`;
|
|
257
|
+
* `ETag`, `Content-Type`, `Content-Range` headers. A failure throws (`416`
|
|
258
|
+
* for a range past the end).
|
|
259
|
+
*/
|
|
260
|
+
async content(file, options = {}) {
|
|
261
|
+
const version = options.version ? `?version=${id(options.version, 'version')}` : '';
|
|
262
|
+
const headers = {};
|
|
263
|
+
if (options.range)
|
|
264
|
+
headers.Range = range(options.range);
|
|
265
|
+
if (options.etag)
|
|
266
|
+
headers['If-None-Match'] = options.etag.startsWith('"') ? options.etag : `"${options.etag}"`;
|
|
267
|
+
const init = { headers };
|
|
268
|
+
if (options.signal)
|
|
269
|
+
init.signal = options.signal;
|
|
270
|
+
const response = await this.#fetch(`${url(this.files, `files/${id(file, 'file')}/content`)}${version}`, init);
|
|
271
|
+
if (response.ok || response.status === 304)
|
|
272
|
+
return response;
|
|
273
|
+
throw rejection(response, await response.text(), 'Download');
|
|
274
|
+
}
|
|
275
|
+
/** The content of a file as a Blob (the whole file, or `range`). */
|
|
276
|
+
async download(file, options = {}) {
|
|
277
|
+
const response = await this.content(file, options);
|
|
278
|
+
if (response.status === 304)
|
|
279
|
+
throw new ProtocolError('Download answered 304: pass no etag to download');
|
|
280
|
+
return response.blob();
|
|
281
|
+
}
|
|
282
|
+
/** The renditions (thumbnails, posters, previews) of a version (`GET /v1/versions/{id}/media`). */
|
|
283
|
+
async media(version) {
|
|
284
|
+
const list = await this.call('GET', `versions/${id(version, 'version')}/media`);
|
|
285
|
+
return Array.isArray(list) ? list : [];
|
|
286
|
+
}
|
|
287
|
+
/** The bytes of a ready rendition (`GET /v1/derivatives/{id}/content` on the content host), or a `range` of them. */
|
|
288
|
+
async derivative(derivative, options = {}) {
|
|
289
|
+
const headers = {};
|
|
290
|
+
if (options.range)
|
|
291
|
+
headers.Range = range(options.range);
|
|
292
|
+
const init = { headers };
|
|
293
|
+
if (options.signal)
|
|
294
|
+
init.signal = options.signal;
|
|
295
|
+
const response = await this.#fetch(url(this.files, `derivatives/${id(derivative, 'derivative')}/content`), init);
|
|
296
|
+
if (response.ok)
|
|
297
|
+
return response;
|
|
298
|
+
throw rejection(response, await response.text(), 'Derivative');
|
|
299
|
+
}
|
|
300
|
+
// ---- any other route ----------------------------------------------
|
|
301
|
+
/**
|
|
302
|
+
* Any metadata route under `/v1` (`path` without it): returns the
|
|
303
|
+
* envelope's `data`; a refusal throws the typed error of `errors.refusal`
|
|
304
|
+
* (`ClientRefusedError` for a route that is not for programs).
|
|
305
|
+
*/
|
|
306
|
+
async call(method, path, options = {}) {
|
|
307
|
+
const params = new URLSearchParams();
|
|
308
|
+
for (const [k, v] of Object.entries(options.query ?? {}))
|
|
309
|
+
if (v !== undefined && v !== '')
|
|
310
|
+
params.set(k, String(v));
|
|
311
|
+
const q = params.toString();
|
|
312
|
+
const clean = path.replace(/^\/+/, '');
|
|
313
|
+
const headers = { Accept: 'application/json' };
|
|
314
|
+
const init = { method, headers };
|
|
315
|
+
if (options.body !== undefined) {
|
|
316
|
+
headers['Content-Type'] = 'application/json';
|
|
317
|
+
init.body = JSON.stringify(options.body);
|
|
318
|
+
}
|
|
319
|
+
if (options.signal)
|
|
320
|
+
init.signal = options.signal;
|
|
321
|
+
return this.#raw(`${url(this.root, clean)}${q ? `?${q}` : ''}`, init, `${method} /v1/${clean.split('?')[0]}`);
|
|
322
|
+
}
|
|
323
|
+
async #raw(target, init, what) {
|
|
324
|
+
const response = await this.#fetch(target, init);
|
|
325
|
+
const text = await response.text();
|
|
326
|
+
if (!response.ok)
|
|
327
|
+
throw rejection(response, text, what);
|
|
328
|
+
const envelope = parse(text);
|
|
329
|
+
if (!envelope || envelope.success !== true)
|
|
330
|
+
throw new ProtocolError(`${what}: the answer is not a success envelope`);
|
|
331
|
+
if (envelope.data === undefined)
|
|
332
|
+
throw new NetworkError(`Empty response from ${what}`, { status: response.status });
|
|
333
|
+
return envelope.data;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** A public id (16 lowercase hex digits), or TypeError. */
|
|
337
|
+
function id(value, what) {
|
|
338
|
+
if (!HEX.test(value))
|
|
339
|
+
throw new TypeError(`${what} is not an id (16 lowercase hex digits)`);
|
|
340
|
+
return value;
|
|
341
|
+
}
|
|
342
|
+
function digest(value) {
|
|
343
|
+
if (!/^[0-9a-f]{64}$/.test(value))
|
|
344
|
+
throw new TypeError('A chunk hash is 64 lowercase hex digits');
|
|
345
|
+
return value;
|
|
346
|
+
}
|
|
347
|
+
function range(r) {
|
|
348
|
+
if (!(Number.isInteger(r.start) && r.start >= 0) || (r.end !== undefined && !(Number.isInteger(r.end) && r.end >= r.start))) {
|
|
349
|
+
throw new TypeError('A range is whole byte offsets, start <= end');
|
|
350
|
+
}
|
|
351
|
+
return `bytes=${r.start}-${r.end ?? ''}`;
|
|
352
|
+
}
|