@knowledgefutures/sdk 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +24 -2
- package/dist/analytics/beacon.d.ts +25 -0
- package/dist/analytics/beacon.js +86 -0
- package/dist/analytics/binding.d.ts +24 -0
- package/dist/analytics/binding.js +46 -0
- package/dist/analytics/collector.d.ts +10 -0
- package/dist/analytics/collector.js +10 -0
- package/dist/analytics/cubes.d.ts +96 -0
- package/dist/analytics/cubes.js +213 -0
- package/dist/analytics/envelope.d.ts +135 -0
- package/dist/analytics/envelope.js +231 -0
- package/dist/analytics/errors.d.ts +24 -0
- package/dist/analytics/errors.js +20 -0
- package/dist/analytics/index.d.ts +38 -0
- package/dist/analytics/index.js +98 -0
- package/dist/analytics/memory.d.ts +35 -0
- package/dist/analytics/memory.js +66 -0
- package/dist/analytics/testing.d.ts +6 -0
- package/dist/analytics/testing.js +6 -0
- package/dist/analytics/types.d.ts +22 -0
- package/dist/analytics/types.js +1 -0
- package/dist/storage/binding.d.ts +76 -0
- package/dist/storage/binding.js +97 -0
- package/dist/storage/brokered.d.ts +21 -0
- package/dist/storage/brokered.js +94 -0
- package/dist/storage/conformance.d.ts +24 -0
- package/dist/storage/conformance.js +311 -0
- package/dist/storage/errors.d.ts +27 -0
- package/dist/storage/errors.js +21 -0
- package/dist/storage/index.d.ts +34 -0
- package/dist/storage/index.js +77 -0
- package/dist/storage/local.d.ts +26 -0
- package/dist/storage/local.js +57 -0
- package/dist/storage/s3.d.ts +22 -0
- package/dist/storage/s3.js +254 -0
- package/dist/storage/shared.d.ts +45 -0
- package/dist/storage/shared.js +167 -0
- package/dist/storage/sigv4.d.ts +50 -0
- package/dist/storage/sigv4.js +128 -0
- package/dist/storage/testing.d.ts +25 -0
- package/dist/storage/testing.js +242 -0
- package/dist/storage/types.d.ts +122 -0
- package/dist/storage/types.js +13 -0
- package/package.json +30 -3
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only error project code sees from storage. Every implementation maps
|
|
3
|
+
* its failures onto these codes — vendor XML, status codes and server quirks
|
|
4
|
+
* never escape — so a `catch` written against the in-memory store handles
|
|
5
|
+
* R2 the same way.
|
|
6
|
+
*/
|
|
7
|
+
export type StorageErrorCode =
|
|
8
|
+
/** No binding: the environment doesn't declare the service (or `public`). */
|
|
9
|
+
'not_configured'
|
|
10
|
+
/** The request itself is wrong: a bad key, metadata name, size or expiry. Fix the call. */
|
|
11
|
+
| 'invalid'
|
|
12
|
+
/** The credentials were refused, or a signed URL was used past its expiry or not as signed. */
|
|
13
|
+
| 'forbidden'
|
|
14
|
+
/** Bigger than the backend accepts. */
|
|
15
|
+
| 'too_large'
|
|
16
|
+
/** The backend is down or unreachable. Safe to retry. */
|
|
17
|
+
| 'unavailable';
|
|
18
|
+
export declare class StorageError extends Error {
|
|
19
|
+
readonly code: StorageErrorCode;
|
|
20
|
+
/** The backend's HTTP status, when there was one. For logs, not for branching. */
|
|
21
|
+
readonly status?: number | undefined;
|
|
22
|
+
readonly name = "StorageError";
|
|
23
|
+
constructor(code: StorageErrorCode, message: string,
|
|
24
|
+
/** The backend's HTTP status, when there was one. For logs, not for branching. */
|
|
25
|
+
status?: number | undefined);
|
|
26
|
+
}
|
|
27
|
+
export declare function isStorageError(e: unknown, code?: StorageErrorCode): e is StorageError;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only error project code sees from storage. Every implementation maps
|
|
3
|
+
* its failures onto these codes — vendor XML, status codes and server quirks
|
|
4
|
+
* never escape — so a `catch` written against the in-memory store handles
|
|
5
|
+
* R2 the same way.
|
|
6
|
+
*/
|
|
7
|
+
export class StorageError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
status;
|
|
10
|
+
name = 'StorageError';
|
|
11
|
+
constructor(code, message,
|
|
12
|
+
/** The backend's HTTP status, when there was one. For logs, not for branching. */
|
|
13
|
+
status) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.status = status;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function isStorageError(e, code) {
|
|
20
|
+
return e instanceof StorageError && (code === undefined || e.code === code);
|
|
21
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PUBLIC_STORAGE_ENV, STORAGE_ENV } from './binding.js';
|
|
2
|
+
import { type S3StoreOptions } from './s3.js';
|
|
3
|
+
import type { Storage } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* `@knowledgefutures/sdk/storage` — files for a project that declares
|
|
6
|
+
* `kf/storage@1`, with nothing to configure:
|
|
7
|
+
*
|
|
8
|
+
* import { storage } from '@knowledgefutures/sdk/storage'
|
|
9
|
+
* await storage.put('covers/1.jpg', bytes, { contentType: 'image/jpeg' })
|
|
10
|
+
* const { key } = await storage.public.upload(file, { prefix: 'covers' })
|
|
11
|
+
* storage.public.url(key)
|
|
12
|
+
*
|
|
13
|
+
* The console writes `KF_STORAGE_URL` (and `KF_STORAGE_PUBLIC_URL` with
|
|
14
|
+
* `"public": true`) into every environment that declares the service; `kf
|
|
15
|
+
* dev` writes the same variables pointing at a local RustFS. Code never sees
|
|
16
|
+
* which backend it has.
|
|
17
|
+
*/
|
|
18
|
+
export type * from './types.js';
|
|
19
|
+
export { StorageError, isStorageError, type StorageErrorCode } from './errors.js';
|
|
20
|
+
export type { S3StoreOptions } from './s3.js';
|
|
21
|
+
type Env = Partial<Record<typeof STORAGE_ENV | typeof PUBLIC_STORAGE_ENV, string | undefined>>;
|
|
22
|
+
/**
|
|
23
|
+
* A client from an explicit environment: a Worker's `env` (when
|
|
24
|
+
* `nodejs_compat` is off, `process.env` is empty), a script's, a test's. A
|
|
25
|
+
* string is the private store's binding alone.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createStorage(env: Env | string, options?: S3StoreOptions): Storage;
|
|
28
|
+
/**
|
|
29
|
+
* The zero-config client. It reads `process.env` on first use, never at
|
|
30
|
+
* import, so importing it costs nothing where storage isn't declared and a
|
|
31
|
+
* missing binding fails at the call that needed it, with the fix in the
|
|
32
|
+
* message.
|
|
33
|
+
*/
|
|
34
|
+
export declare const storage: Storage;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { parseBinding, PUBLIC_STORAGE_ENV, STORAGE_ENV } from './binding.js';
|
|
2
|
+
import { createBrokeredStore } from './brokered.js';
|
|
3
|
+
import { StorageError } from './errors.js';
|
|
4
|
+
import { createS3Store } from './s3.js';
|
|
5
|
+
export { StorageError, isStorageError } from './errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* A client from an explicit environment: a Worker's `env` (when
|
|
8
|
+
* `nodejs_compat` is off, `process.env` is empty), a script's, a test's. A
|
|
9
|
+
* string is the private store's binding alone.
|
|
10
|
+
*/
|
|
11
|
+
export function createStorage(env, options) {
|
|
12
|
+
const vars = typeof env === 'string' ? { [STORAGE_ENV]: env } : env;
|
|
13
|
+
const priv = vars[STORAGE_ENV];
|
|
14
|
+
if (!priv)
|
|
15
|
+
throw unconfigured(STORAGE_ENV, 'kf/storage@1');
|
|
16
|
+
// Either form (binding.ts): `s3+https://…` with fixed credentials (kf dev, tests), or
|
|
17
|
+
// `https://<instance>:<secret>@<storage service>/`, which exchanges for short-lived ones.
|
|
18
|
+
const parsed = parseBinding(priv, STORAGE_ENV);
|
|
19
|
+
const store = parsed.kind === 'static'
|
|
20
|
+
? createS3Store(parsed.binding, options)
|
|
21
|
+
: createBrokeredStore({ ...parsed.binding, store: 'private' }, options);
|
|
22
|
+
let pub;
|
|
23
|
+
return {
|
|
24
|
+
...store,
|
|
25
|
+
get public() {
|
|
26
|
+
if (pub)
|
|
27
|
+
return pub;
|
|
28
|
+
const value = vars[PUBLIC_STORAGE_ENV];
|
|
29
|
+
if (!value)
|
|
30
|
+
throw unconfigured(PUBLIC_STORAGE_ENV, '"public": true under kf/storage@1');
|
|
31
|
+
const p = parseBinding(value, PUBLIC_STORAGE_ENV);
|
|
32
|
+
if (!p.binding.publicBase) {
|
|
33
|
+
throw new StorageError('not_configured', `${PUBLIC_STORAGE_ENV} has no base URL`);
|
|
34
|
+
}
|
|
35
|
+
const publicBase = p.binding.publicBase;
|
|
36
|
+
pub =
|
|
37
|
+
p.kind === 'static'
|
|
38
|
+
? createS3Store({ ...p.binding, publicBase }, options)
|
|
39
|
+
: createBrokeredStore({ ...p.binding, store: 'public', publicBase }, options);
|
|
40
|
+
return pub;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The zero-config client. It reads `process.env` on first use, never at
|
|
46
|
+
* import, so importing it costs nothing where storage isn't declared and a
|
|
47
|
+
* missing binding fails at the call that needed it, with the fix in the
|
|
48
|
+
* message.
|
|
49
|
+
*/
|
|
50
|
+
export const storage = (() => {
|
|
51
|
+
let resolved;
|
|
52
|
+
const current = () => {
|
|
53
|
+
if (resolved)
|
|
54
|
+
return resolved;
|
|
55
|
+
const env = globalThis.process?.env;
|
|
56
|
+
if (!env?.[STORAGE_ENV])
|
|
57
|
+
throw unconfigured(STORAGE_ENV, 'kf/storage@1');
|
|
58
|
+
resolved = createStorage(env);
|
|
59
|
+
return resolved;
|
|
60
|
+
};
|
|
61
|
+
// async, so a missing binding is a rejected promise like every other failure, not a throw.
|
|
62
|
+
const methods = {
|
|
63
|
+
put: async (...a) => current().put(...a),
|
|
64
|
+
get: async (...a) => current().get(...a),
|
|
65
|
+
head: async (...a) => current().head(...a),
|
|
66
|
+
delete: async (...a) => current().delete(...a),
|
|
67
|
+
list: async (...a) => current().list(...a),
|
|
68
|
+
signUpload: async (...a) => current().signUpload(...a),
|
|
69
|
+
signDownload: async (...a) => current().signDownload(...a),
|
|
70
|
+
upload: async (...a) => current().upload(...a),
|
|
71
|
+
};
|
|
72
|
+
return Object.defineProperty(methods, 'public', { get: () => current().public });
|
|
73
|
+
})();
|
|
74
|
+
function unconfigured(name, declare) {
|
|
75
|
+
return new StorageError('not_configured', `${name} is not set: declare ${declare} in kf.project.jsonc for this environment, ` +
|
|
76
|
+
`run under \`kf dev\`, or pass the environment to createStorage(env)`);
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setting up a LOCAL S3 server — the one `kf dev` runs, or one in a test —
|
|
3
|
+
* over the plain S3 API: create buckets, and let anyone read the public one.
|
|
4
|
+
* No vendor CLI, so it works against any server that passes the conformance
|
|
5
|
+
* suite (RustFS today). The console never uses this: deployed buckets are
|
|
6
|
+
* provisioned by kf/storage@1's adapter through the provider's own API.
|
|
7
|
+
*/
|
|
8
|
+
export interface LocalServer {
|
|
9
|
+
/** `http://localhost:<port>` */
|
|
10
|
+
endpoint: string;
|
|
11
|
+
accessKeyId: string;
|
|
12
|
+
secretAccessKey: string;
|
|
13
|
+
region?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface LocalBucket {
|
|
16
|
+
name: string;
|
|
17
|
+
/** Anonymous GET on every object: the public store. */
|
|
18
|
+
publicRead?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface EnsureOptions {
|
|
21
|
+
/** How long to wait for the server to accept connections. Default 30s. */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
}
|
|
25
|
+
/** Idempotent: buckets that exist are left alone, and the policy is simply set again. */
|
|
26
|
+
export declare function ensureBuckets(server: LocalServer, buckets: LocalBucket[], options?: EnsureOptions): Promise<void>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { StorageError } from './errors.js';
|
|
2
|
+
import { sha256Hex, signRequest } from './sigv4.js';
|
|
3
|
+
/** Idempotent: buckets that exist are left alone, and the policy is simply set again. */
|
|
4
|
+
export async function ensureBuckets(server, buckets, options = {}) {
|
|
5
|
+
const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
6
|
+
const credentials = {
|
|
7
|
+
accessKeyId: server.accessKeyId,
|
|
8
|
+
secretAccessKey: server.secretAccessKey,
|
|
9
|
+
region: server.region ?? 'us-east-1',
|
|
10
|
+
};
|
|
11
|
+
const deadline = Date.now() + (options.timeoutMs ?? 30_000);
|
|
12
|
+
const put = async (url, body = '') => {
|
|
13
|
+
for (;;) {
|
|
14
|
+
const headers = await signRequest({
|
|
15
|
+
method: 'PUT',
|
|
16
|
+
url,
|
|
17
|
+
payloadHash: await sha256Hex(body),
|
|
18
|
+
credentials,
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
return await doFetch(url, { method: 'PUT', headers, ...(body ? { body } : {}) });
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
// Not listening yet: the container has started, the server hasn't.
|
|
25
|
+
if (Date.now() > deadline) {
|
|
26
|
+
throw new StorageError('unavailable', `${server.endpoint} never answered: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
for (const b of buckets) {
|
|
33
|
+
const res = await put(new URL(`${server.endpoint}/${b.name}`));
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
if (!res.ok && !/BucketAlreadyOwnedByYou|BucketAlreadyExists/.test(text)) {
|
|
36
|
+
throw new StorageError('unavailable', `creating bucket ${b.name}: ${res.status} ${text.slice(0, 200)}`);
|
|
37
|
+
}
|
|
38
|
+
if (!b.publicRead)
|
|
39
|
+
continue;
|
|
40
|
+
const policy = JSON.stringify({
|
|
41
|
+
Version: '2012-10-17',
|
|
42
|
+
Statement: [
|
|
43
|
+
{
|
|
44
|
+
Effect: 'Allow',
|
|
45
|
+
Principal: { AWS: ['*'] },
|
|
46
|
+
Action: ['s3:GetObject'],
|
|
47
|
+
Resource: [`arn:aws:s3:::${b.name}/*`],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
const set = await put(new URL(`${server.endpoint}/${b.name}?policy=`), policy);
|
|
52
|
+
if (!set.ok) {
|
|
53
|
+
throw new StorageError('unavailable', `making ${b.name} publicly readable: ${set.status} ${(await set.text()).slice(0, 200)}`);
|
|
54
|
+
}
|
|
55
|
+
await set.body?.cancel();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { StorageBinding } from './binding.js';
|
|
2
|
+
import type { ListResult, PublicStore, Store } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A store over the S3 API — R2 behind the console, RustFS under `kf dev`.
|
|
5
|
+
* Path-style URLs (`<endpoint>/<bucket>/<key>`), which both accept, so there
|
|
6
|
+
* is one code path. Nothing here knows which backend it is talking to.
|
|
7
|
+
*/
|
|
8
|
+
export interface S3StoreOptions {
|
|
9
|
+
/** For tests. Defaults to the global fetch. */
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
/** With temporary credentials (brokered.ts): signed into every request and presigned URL. */
|
|
12
|
+
sessionToken?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function createS3Store(binding: StorageBinding & {
|
|
15
|
+
publicBase: string;
|
|
16
|
+
}, options?: S3StoreOptions): PublicStore;
|
|
17
|
+
export declare function createS3Store(binding: StorageBinding, options?: S3StoreOptions): Store;
|
|
18
|
+
/**
|
|
19
|
+
* ListObjectsV2 by hand: Workers have no DOMParser, and the response is a
|
|
20
|
+
* flat, well-known shape.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseList(xml: string): ListResult;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { StorageError } from './errors.js';
|
|
2
|
+
import { attachment, checkExpiresIn, DOWNLOAD_EXPIRES_IN, UPLOAD_EXPIRES_IN, checkKey, checkMetadata, checkPrefix, checkSignUpload, DEFAULT_CONTENT_TYPE, IMMUTABLE, publicDisposition, SHORT_PUBLIC, toBytes, uploadAsPut, } from './shared.js';
|
|
3
|
+
import { encodeKey, EMPTY_SHA256, presign, sha256Hex, signRequest, } from './sigv4.js';
|
|
4
|
+
export function createS3Store(binding, options = {}) {
|
|
5
|
+
const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
6
|
+
const credentials = {
|
|
7
|
+
accessKeyId: binding.accessKeyId,
|
|
8
|
+
secretAccessKey: binding.secretAccessKey,
|
|
9
|
+
region: binding.region,
|
|
10
|
+
...(options.sessionToken ? { sessionToken: options.sessionToken } : {}),
|
|
11
|
+
};
|
|
12
|
+
const isPublic = binding.publicBase !== undefined;
|
|
13
|
+
const objectUrl = (key) => new URL(`${binding.endpoint}/${binding.bucket}/${encodeKey(key)}`);
|
|
14
|
+
async function send(method, url, o = {}) {
|
|
15
|
+
const headers = await signRequest({
|
|
16
|
+
method,
|
|
17
|
+
url,
|
|
18
|
+
headers: o.headers ?? {},
|
|
19
|
+
payloadHash: o.payloadHash ?? EMPTY_SHA256,
|
|
20
|
+
credentials,
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
return await doFetch(url, {
|
|
24
|
+
method,
|
|
25
|
+
headers,
|
|
26
|
+
...(o.body ? { body: o.body } : {}),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
throw new StorageError('unavailable', `storage is unreachable: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function put(key, body, opts = {}) {
|
|
34
|
+
checkKey(key);
|
|
35
|
+
const metadata = checkMetadata(opts.metadata);
|
|
36
|
+
const { bytes, contentType } = await toBytes(body, opts.contentType);
|
|
37
|
+
const cacheControl = opts.cacheControl ?? (isPublic ? SHORT_PUBLIC : undefined);
|
|
38
|
+
const disposition = isPublic
|
|
39
|
+
? publicDisposition(contentType, opts.contentDisposition)
|
|
40
|
+
: opts.contentDisposition;
|
|
41
|
+
const headers = { 'content-type': contentType };
|
|
42
|
+
if (cacheControl)
|
|
43
|
+
headers['cache-control'] = cacheControl;
|
|
44
|
+
if (disposition)
|
|
45
|
+
headers['content-disposition'] = disposition;
|
|
46
|
+
for (const [name, value] of Object.entries(metadata))
|
|
47
|
+
headers[`x-amz-meta-${name}`] = value;
|
|
48
|
+
const res = await send('PUT', objectUrl(key), {
|
|
49
|
+
headers,
|
|
50
|
+
body: bytes,
|
|
51
|
+
payloadHash: await sha256Hex(bytes),
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
throw await failure(res, `put ${key}`);
|
|
55
|
+
await res.body?.cancel();
|
|
56
|
+
const stored = {
|
|
57
|
+
key,
|
|
58
|
+
size: bytes.byteLength,
|
|
59
|
+
etag: unquote(res.headers.get('etag') ?? ''),
|
|
60
|
+
uploaded: new Date(res.headers.get('date') ?? Date.now()),
|
|
61
|
+
contentType,
|
|
62
|
+
metadata,
|
|
63
|
+
};
|
|
64
|
+
if (cacheControl)
|
|
65
|
+
stored.cacheControl = cacheControl;
|
|
66
|
+
if (disposition)
|
|
67
|
+
stored.contentDisposition = disposition;
|
|
68
|
+
return stored;
|
|
69
|
+
}
|
|
70
|
+
async function head(key) {
|
|
71
|
+
checkKey(key);
|
|
72
|
+
const res = await send('HEAD', objectUrl(key));
|
|
73
|
+
if (res.status === 404)
|
|
74
|
+
return null;
|
|
75
|
+
if (!res.ok)
|
|
76
|
+
throw await failure(res, `head ${key}`);
|
|
77
|
+
return describe(key, res);
|
|
78
|
+
}
|
|
79
|
+
async function get(key) {
|
|
80
|
+
checkKey(key);
|
|
81
|
+
const res = await send('GET', objectUrl(key));
|
|
82
|
+
if (res.status === 404) {
|
|
83
|
+
await res.body?.cancel();
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (!res.ok)
|
|
87
|
+
throw await failure(res, `get ${key}`);
|
|
88
|
+
const meta = describe(key, res);
|
|
89
|
+
const body = res.body ?? new Response('').body;
|
|
90
|
+
return {
|
|
91
|
+
...meta,
|
|
92
|
+
body,
|
|
93
|
+
arrayBuffer: () => new Response(body).arrayBuffer(),
|
|
94
|
+
text: () => new Response(body).text(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function del(key) {
|
|
98
|
+
checkKey(key);
|
|
99
|
+
const res = await send('DELETE', objectUrl(key));
|
|
100
|
+
// 204 for a delete; 404 from some backends for a key that was never there — both are success.
|
|
101
|
+
if (!res.ok && res.status !== 404)
|
|
102
|
+
throw await failure(res, `delete ${key}`);
|
|
103
|
+
await res.body?.cancel();
|
|
104
|
+
}
|
|
105
|
+
async function list(opts = {}) {
|
|
106
|
+
checkPrefix(opts.prefix);
|
|
107
|
+
const limit = opts.limit ?? 1000;
|
|
108
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
|
|
109
|
+
throw new StorageError('invalid', 'limit is 1–1000');
|
|
110
|
+
}
|
|
111
|
+
const url = new URL(`${binding.endpoint}/${binding.bucket}`);
|
|
112
|
+
url.searchParams.set('list-type', '2');
|
|
113
|
+
url.searchParams.set('max-keys', String(limit));
|
|
114
|
+
if (opts.prefix)
|
|
115
|
+
url.searchParams.set('prefix', opts.prefix);
|
|
116
|
+
if (opts.cursor)
|
|
117
|
+
url.searchParams.set('continuation-token', opts.cursor);
|
|
118
|
+
const res = await send('GET', url);
|
|
119
|
+
if (!res.ok)
|
|
120
|
+
throw await failure(res, 'list');
|
|
121
|
+
return parseList(await res.text());
|
|
122
|
+
}
|
|
123
|
+
async function signUpload(key, o) {
|
|
124
|
+
checkKey(key);
|
|
125
|
+
checkSignUpload(o);
|
|
126
|
+
const expiresIn = checkExpiresIn(o.expiresIn, UPLOAD_EXPIRES_IN);
|
|
127
|
+
const cacheControl = o.cacheControl ?? (isPublic ? SHORT_PUBLIC : undefined);
|
|
128
|
+
// Sent by the browser. content-length is signed too, but the browser sets
|
|
129
|
+
// it from the body, so it is not in what the caller must send.
|
|
130
|
+
const sent = { 'content-type': o.contentType };
|
|
131
|
+
if (cacheControl)
|
|
132
|
+
sent['cache-control'] = cacheControl;
|
|
133
|
+
// Signed, so a browser cannot upload HTML to the public store as inline.
|
|
134
|
+
const disposition = isPublic ? publicDisposition(o.contentType, undefined) : undefined;
|
|
135
|
+
if (disposition)
|
|
136
|
+
sent['content-disposition'] = disposition;
|
|
137
|
+
const url = await presign({
|
|
138
|
+
method: 'PUT',
|
|
139
|
+
url: objectUrl(key),
|
|
140
|
+
headers: { ...sent, 'content-length': String(o.size) },
|
|
141
|
+
expiresIn,
|
|
142
|
+
credentials,
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
key,
|
|
146
|
+
url,
|
|
147
|
+
method: 'PUT',
|
|
148
|
+
headers: sent,
|
|
149
|
+
expiresAt: new Date(Date.now() + expiresIn * 1000),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async function signDownload(key, o = {}) {
|
|
153
|
+
checkKey(key);
|
|
154
|
+
const expiresIn = checkExpiresIn(o.expiresIn, DOWNLOAD_EXPIRES_IN);
|
|
155
|
+
const url = objectUrl(key);
|
|
156
|
+
if (o.filename)
|
|
157
|
+
url.searchParams.set('response-content-disposition', attachment(o.filename));
|
|
158
|
+
return presign({ method: 'GET', url, expiresIn, credentials });
|
|
159
|
+
}
|
|
160
|
+
const store = {
|
|
161
|
+
put,
|
|
162
|
+
get,
|
|
163
|
+
head,
|
|
164
|
+
delete: del,
|
|
165
|
+
list,
|
|
166
|
+
signUpload,
|
|
167
|
+
signDownload,
|
|
168
|
+
upload: async (file, options) => {
|
|
169
|
+
const p = uploadAsPut(file, options, isPublic ? { cacheControl: IMMUTABLE } : {});
|
|
170
|
+
return put(p.key, p.body, p.options);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
if (!isPublic)
|
|
174
|
+
return store;
|
|
175
|
+
const base = binding.publicBase;
|
|
176
|
+
return {
|
|
177
|
+
...store,
|
|
178
|
+
url: (key) => {
|
|
179
|
+
checkKey(key);
|
|
180
|
+
return `${base}/${encodeKey(key)}`;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/* ------------------------------ responses ------------------------------ */
|
|
185
|
+
function describe(key, res) {
|
|
186
|
+
const metadata = {};
|
|
187
|
+
for (const [name, value] of res.headers) {
|
|
188
|
+
if (name.startsWith('x-amz-meta-'))
|
|
189
|
+
metadata[name.slice('x-amz-meta-'.length)] = value;
|
|
190
|
+
}
|
|
191
|
+
const o = {
|
|
192
|
+
key,
|
|
193
|
+
size: Number(res.headers.get('content-length') ?? 0),
|
|
194
|
+
etag: unquote(res.headers.get('etag') ?? ''),
|
|
195
|
+
uploaded: new Date(res.headers.get('last-modified') ?? 0),
|
|
196
|
+
contentType: res.headers.get('content-type') ?? DEFAULT_CONTENT_TYPE,
|
|
197
|
+
metadata,
|
|
198
|
+
};
|
|
199
|
+
const cacheControl = res.headers.get('cache-control');
|
|
200
|
+
const disposition = res.headers.get('content-disposition');
|
|
201
|
+
if (cacheControl)
|
|
202
|
+
o.cacheControl = cacheControl;
|
|
203
|
+
if (disposition)
|
|
204
|
+
o.contentDisposition = disposition;
|
|
205
|
+
return o;
|
|
206
|
+
}
|
|
207
|
+
/** Every backend failure as a StorageError — the vendor's XML never reaches the caller. */
|
|
208
|
+
async function failure(res, what) {
|
|
209
|
+
const text = res.body ? await res.text().catch(() => '') : '';
|
|
210
|
+
const code = tag(text, 'Code') ?? '';
|
|
211
|
+
const message = tag(text, 'Message') ?? res.statusText;
|
|
212
|
+
const detail = `${what}: ${code || res.status}${message ? ` — ${message}` : ''}`;
|
|
213
|
+
if (res.status === 413 || code === 'EntityTooLarge') {
|
|
214
|
+
return new StorageError('too_large', detail, res.status);
|
|
215
|
+
}
|
|
216
|
+
if (res.status === 401 || res.status === 403)
|
|
217
|
+
return new StorageError('forbidden', detail, res.status);
|
|
218
|
+
if (code === 'NoSuchBucket') {
|
|
219
|
+
return new StorageError('not_configured', `${detail} (the binding names a bucket that does not exist)`, res.status);
|
|
220
|
+
}
|
|
221
|
+
if (res.status === 429 || res.status >= 500)
|
|
222
|
+
return new StorageError('unavailable', detail, res.status);
|
|
223
|
+
return new StorageError('invalid', detail, res.status);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* ListObjectsV2 by hand: Workers have no DOMParser, and the response is a
|
|
227
|
+
* flat, well-known shape.
|
|
228
|
+
*/
|
|
229
|
+
export function parseList(xml) {
|
|
230
|
+
const objects = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)].map(([, c]) => ({
|
|
231
|
+
key: tag(c, 'Key') ?? '',
|
|
232
|
+
size: Number(tag(c, 'Size') ?? 0),
|
|
233
|
+
etag: unquote(tag(c, 'ETag') ?? ''),
|
|
234
|
+
uploaded: new Date(tag(c, 'LastModified') ?? 0),
|
|
235
|
+
}));
|
|
236
|
+
const truncated = tag(xml, 'IsTruncated') === 'true';
|
|
237
|
+
const cursor = tag(xml, 'NextContinuationToken');
|
|
238
|
+
return truncated && cursor ? { objects, cursor } : { objects };
|
|
239
|
+
}
|
|
240
|
+
function tag(xml, name) {
|
|
241
|
+
const m = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(xml);
|
|
242
|
+
return m ? unescapeXml(m[1]) : undefined;
|
|
243
|
+
}
|
|
244
|
+
function unescapeXml(s) {
|
|
245
|
+
return s.replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (_, e) => {
|
|
246
|
+
if (e[0] === '#') {
|
|
247
|
+
return String.fromCodePoint(e[1] === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
|
|
248
|
+
}
|
|
249
|
+
return { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[e];
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function unquote(etag) {
|
|
253
|
+
return etag.replace(/^W\//, '').replace(/^"|"$/g, '');
|
|
254
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Body, PutOptions, SignUploadOptions, Upload, UploadOptions } from './types.js';
|
|
2
|
+
export declare const MAX_KEY_BYTES = 1024;
|
|
3
|
+
export declare const MAX_EXPIRES_IN = 604800;
|
|
4
|
+
/** Default lifetimes of a signed upload and a signed download, in seconds. */
|
|
5
|
+
export declare const UPLOAD_EXPIRES_IN = 600;
|
|
6
|
+
export declare const DOWNLOAD_EXPIRES_IN = 300;
|
|
7
|
+
/**
|
|
8
|
+
* A key is 1–1024 UTF-8 bytes with no leading `/`, no `.` or `..` segment and
|
|
9
|
+
* no control characters. The segment rule is not S3's: a URL path normalizes
|
|
10
|
+
* `a/../b` to `b` before it is sent, so such a key would silently name a
|
|
11
|
+
* different object.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkKey(key: string): void;
|
|
14
|
+
export declare function checkPrefix(prefix: string | undefined): void;
|
|
15
|
+
export declare function checkMetadata(metadata: Record<string, string> | undefined): Record<string, string>;
|
|
16
|
+
export declare function checkExpiresIn(seconds: number | undefined, fallback: number): number;
|
|
17
|
+
export declare function checkSignUpload(o: SignUploadOptions): void;
|
|
18
|
+
export declare const DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
19
|
+
/** Bytes and a content type, from anything `put` accepts. */
|
|
20
|
+
export declare function toBytes(body: Body, contentType: string | undefined): Promise<{
|
|
21
|
+
bytes: Uint8Array;
|
|
22
|
+
contentType: string;
|
|
23
|
+
}>;
|
|
24
|
+
/** `upload`'s arguments as a `put`: a generated key under the prefix, the file's extension kept. */
|
|
25
|
+
export declare function uploadAsPut(file: Blob | Upload, options: UploadOptions | undefined, defaults: {
|
|
26
|
+
cacheControl?: string;
|
|
27
|
+
}): {
|
|
28
|
+
key: string;
|
|
29
|
+
body: Body;
|
|
30
|
+
options: PutOptions;
|
|
31
|
+
};
|
|
32
|
+
/** `.jpg` from `Cover Art.JPG`; nothing when there is no sensible extension. */
|
|
33
|
+
export declare function extensionOf(name: string): string;
|
|
34
|
+
/** How the public store caches: generated keys never change, chosen keys might. */
|
|
35
|
+
export declare const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
36
|
+
export declare const SHORT_PUBLIC = "public, max-age=300";
|
|
37
|
+
export declare function rendersInline(contentType: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The disposition a public object is stored with. A caller's own `attachment;
|
|
40
|
+
* filename=…` is kept; asking for `inline` on a type outside the list is not
|
|
41
|
+
* honoured.
|
|
42
|
+
*/
|
|
43
|
+
export declare function publicDisposition(contentType: string, requested: string | undefined): string | undefined;
|
|
44
|
+
/** An attachment disposition, safe for any file name (RFC 6266 + RFC 5987). */
|
|
45
|
+
export declare function attachment(filename: string): string;
|