@knowledgefutures/sdk 0.0.0-stage → 0.2.1
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 +71 -2
- 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/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/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/dist/whistle/hono.d.ts +42 -0
- package/dist/whistle/hono.js +89 -0
- package/dist/whistle/index.d.ts +135 -0
- package/dist/whistle/index.js +249 -0
- package/dist/whistle/wire.d.ts +64 -0
- package/dist/whistle/wire.js +98 -0
- package/package.json +34 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Knowledge Futures, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,72 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @knowledgefutures/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Clients for the services a [Knowledge Futures Console](https://console.knowledgefutures.org)
|
|
4
|
+
project declares, with nothing to configure: the console writes each service's
|
|
5
|
+
binding into every environment that declares it, and `kf dev` writes the same
|
|
6
|
+
variables pointing at local stand-ins.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm i @knowledgefutures/sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { storage } from '@knowledgefutures/sdk/storage' // kf/storage@1
|
|
14
|
+
await storage.put('covers/1.jpg', bytes, { contentType: 'image/jpeg' })
|
|
15
|
+
|
|
16
|
+
// In tests, an in-memory store with the same interface:
|
|
17
|
+
import { memoryStorage } from '@knowledgefutures/sdk/storage/testing'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`createStorage(env)` builds a client from an explicit environment, such as a
|
|
21
|
+
Worker's `env` when `nodejs_compat` is off.
|
|
22
|
+
|
|
23
|
+
## Whistle (`kf/whistle@1`)
|
|
24
|
+
|
|
25
|
+
On a Worker the console hosts, declaring `kf/whistle@1` is enough for errors:
|
|
26
|
+
Whistle tails every run, so uncaught exceptions, `console.error(err)` and cron
|
|
27
|
+
runs reach it with no code. Log `console.error(err)`, not `err.message`, to keep
|
|
28
|
+
the stack. This entry point is for what a run can't say by itself.
|
|
29
|
+
|
|
30
|
+
On Hono, one line, registered before the routes:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { whistle } from '@knowledgefutures/sdk/whistle'
|
|
34
|
+
|
|
35
|
+
app.use(whistle())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
It records a 403, or a 401 to a request that sent an `Authorization` header,
|
|
39
|
+
as `access.denied` (Whistle alerts on bursts per client and per user), and a
|
|
40
|
+
successful write under `/admin` or `/api/admin` as `admin.action`, with the
|
|
41
|
+
route pattern and the user. The user is `c.var.user.id` unless
|
|
42
|
+
`user: (c) => …` says otherwise; `admin` sets the prefixes. On a Worker it
|
|
43
|
+
also logs an error a custom `onError` turned into a 5xx, which the tail would
|
|
44
|
+
otherwise never see.
|
|
45
|
+
|
|
46
|
+
The rest by hand:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { checkIn, requestFields, telemetryFromEnv } from '@knowledgefutures/sdk/whistle'
|
|
50
|
+
|
|
51
|
+
const t = telemetryFromEnv(env, { waitUntil })
|
|
52
|
+
t.securityEvent('auth.failed', {
|
|
53
|
+
actor: `user:${id}`,
|
|
54
|
+
outcome: 'failure',
|
|
55
|
+
...requestFields(request),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// A job that isn't a Worker cron: Whistle says when it fails or stops running.
|
|
59
|
+
await checkIn(env.KF_WHISTLE_URL, 'nightly-backup', { every: 1440 })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`captureError(err)` is for an error the app handles and doesn't log; on a
|
|
63
|
+
tailed Worker, one it also logs or throws would count twice. `sentryDsn(url)`
|
|
64
|
+
turns `KF_WHISTLE_URL` into a Sentry DSN, for apps that keep a Sentry SDK.
|
|
65
|
+
`actor` is an id (`user:<id>`), never an email or a name, and `detail` is
|
|
66
|
+
scrubbed of anything named like a credential. Events go under
|
|
67
|
+
`KF_WHISTLE_PROJECT` and `KF_WHISTLE_ENVIRONMENT`, which the console and
|
|
68
|
+
`kf dev` set; pass `project` and `environment` elsewhere.
|
|
69
|
+
|
|
70
|
+
What a project declares and what the services give it:
|
|
71
|
+
[console.knowledgefutures.org/llms.txt](https://console.knowledgefutures.org/llms.txt).
|
|
72
|
+
Source: `packages/sdk/` in [knowledgefutures/kf-console](https://github.com/knowledgefutures/kf-console).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `KF_STORAGE_URL` and `KF_STORAGE_PUBLIC_URL` — the whole contract between
|
|
3
|
+
* the console, which provisions a bucket and writes the binding into a
|
|
4
|
+
* project's environment, and the SDK, which reads it. Both import this file,
|
|
5
|
+
* so the format has exactly one definition.
|
|
6
|
+
*
|
|
7
|
+
* s3+https://<accessKeyId>:<secretAccessKey>@<host>[:port]/<bucket>?region=<r>[&base=<public origin>]
|
|
8
|
+
*
|
|
9
|
+
* `s3+http` only for a local server. Credentials are percent-encoded (secrets
|
|
10
|
+
* contain `/` and `+`). `base` is present on the public store only: where
|
|
11
|
+
* `url(key)` points. One variable per store, so a store can never be half
|
|
12
|
+
* configured.
|
|
13
|
+
*
|
|
14
|
+
* Not for project code, and not published: projects call `createStorage` /
|
|
15
|
+
* `storage`, which read these variables. Only what WRITES them imports this
|
|
16
|
+
* entry point — the console's adapter and `kf dev`, both from the workspace.
|
|
17
|
+
*/
|
|
18
|
+
export interface StorageBinding {
|
|
19
|
+
/** `https://<account>.r2.cloudflarestorage.com`, or `http://localhost:9000` for a local server. */
|
|
20
|
+
endpoint: string;
|
|
21
|
+
bucket: string;
|
|
22
|
+
accessKeyId: string;
|
|
23
|
+
secretAccessKey: string;
|
|
24
|
+
/** `auto` for R2; a local server accepts any, conventionally `us-east-1`. */
|
|
25
|
+
region: string;
|
|
26
|
+
/** The public store's origin, e.g. `https://my-project.kfassets.example`. */
|
|
27
|
+
publicBase?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare const STORAGE_ENV = "KF_STORAGE_URL";
|
|
30
|
+
export declare const PUBLIC_STORAGE_ENV = "KF_STORAGE_PUBLIC_URL";
|
|
31
|
+
export declare function encodeStorageBinding(b: StorageBinding): string;
|
|
32
|
+
export declare function decodeStorageBinding(value: string, name?: string): StorageBinding;
|
|
33
|
+
/**
|
|
34
|
+
* The deployed form (storage plan §8): no vendor credential at all, only the
|
|
35
|
+
* instance's id and secret at the storage service, which signs short-lived
|
|
36
|
+
* R2 credentials for its bucket on request.
|
|
37
|
+
*
|
|
38
|
+
* https://<instance>:<secret>@<storage service host>/[?store=public&base=<public origin>]
|
|
39
|
+
*
|
|
40
|
+
* The static `s3+…` form above stays for `kf dev` (RustFS) and tests.
|
|
41
|
+
*/
|
|
42
|
+
export interface BrokeredBinding {
|
|
43
|
+
/** The storage service's origin, e.g. `https://console-storage-service.kfutures.org`. */
|
|
44
|
+
service: string;
|
|
45
|
+
instance: string;
|
|
46
|
+
secret: string;
|
|
47
|
+
store: 'private' | 'public';
|
|
48
|
+
/** The public store's origin, where `url(key)` points. */
|
|
49
|
+
publicBase?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* What the storage service's `POST /v1/credentials` answers: R2 temporary
|
|
53
|
+
* credentials for one bucket. The service (its own repo) defines the response
|
|
54
|
+
* and this is what the SDK reads; `brokered.test.ts` and the service's tests
|
|
55
|
+
* hold the same example response, so a change to either side fails a test.
|
|
56
|
+
*/
|
|
57
|
+
export interface IssuedCredentials {
|
|
58
|
+
endpoint: string;
|
|
59
|
+
bucket: string;
|
|
60
|
+
region: string;
|
|
61
|
+
accessKeyId: string;
|
|
62
|
+
secretAccessKey: string;
|
|
63
|
+
sessionToken: string;
|
|
64
|
+
/** ISO time. */
|
|
65
|
+
expiresAt: string;
|
|
66
|
+
}
|
|
67
|
+
export type ParsedBinding = {
|
|
68
|
+
kind: 'static';
|
|
69
|
+
binding: StorageBinding;
|
|
70
|
+
} | {
|
|
71
|
+
kind: 'brokered';
|
|
72
|
+
binding: BrokeredBinding;
|
|
73
|
+
};
|
|
74
|
+
export declare function encodeBrokeredBinding(b: BrokeredBinding): string;
|
|
75
|
+
/** Either form; the scheme says which. */
|
|
76
|
+
export declare function parseBinding(value: string, name?: string): ParsedBinding;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { StorageError } from './errors.js';
|
|
2
|
+
export const STORAGE_ENV = 'KF_STORAGE_URL';
|
|
3
|
+
export const PUBLIC_STORAGE_ENV = 'KF_STORAGE_PUBLIC_URL';
|
|
4
|
+
const BUCKET = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
|
|
5
|
+
export function encodeStorageBinding(b) {
|
|
6
|
+
const endpoint = new URL(b.endpoint);
|
|
7
|
+
if (endpoint.protocol !== 'https:' && endpoint.protocol !== 'http:') {
|
|
8
|
+
throw new StorageError('invalid', `endpoint must be http(s): ${b.endpoint}`);
|
|
9
|
+
}
|
|
10
|
+
if (!BUCKET.test(b.bucket))
|
|
11
|
+
throw new StorageError('invalid', `not a bucket name: ${b.bucket}`);
|
|
12
|
+
const query = new URLSearchParams({ region: b.region });
|
|
13
|
+
if (b.publicBase)
|
|
14
|
+
query.set('base', trimSlash(b.publicBase));
|
|
15
|
+
return (`s3+${endpoint.protocol}//` +
|
|
16
|
+
`${encodeURIComponent(b.accessKeyId)}:${encodeURIComponent(b.secretAccessKey)}@` +
|
|
17
|
+
`${endpoint.host}/${b.bucket}?${query}`);
|
|
18
|
+
}
|
|
19
|
+
export function decodeStorageBinding(value, name = STORAGE_ENV) {
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(value.trim());
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw bad(name, 'not a URL');
|
|
26
|
+
}
|
|
27
|
+
const scheme = /^s3\+(https?):$/.exec(url.protocol)?.[1];
|
|
28
|
+
if (!scheme)
|
|
29
|
+
throw bad(name, 'the scheme is s3+https (or s3+http for a local server)');
|
|
30
|
+
const bucket = url.pathname.replace(/^\/+|\/+$/g, '');
|
|
31
|
+
if (!BUCKET.test(bucket))
|
|
32
|
+
throw bad(name, 'the path is the bucket name');
|
|
33
|
+
if (!url.username || !url.password)
|
|
34
|
+
throw bad(name, 'credentials are missing');
|
|
35
|
+
const region = url.searchParams.get('region');
|
|
36
|
+
if (!region)
|
|
37
|
+
throw bad(name, 'region is missing');
|
|
38
|
+
const base = url.searchParams.get('base');
|
|
39
|
+
return {
|
|
40
|
+
endpoint: `${scheme}://${url.host}`,
|
|
41
|
+
bucket,
|
|
42
|
+
accessKeyId: decodeURIComponent(url.username),
|
|
43
|
+
secretAccessKey: decodeURIComponent(url.password),
|
|
44
|
+
region,
|
|
45
|
+
...(base ? { publicBase: trimSlash(base) } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function trimSlash(s) {
|
|
49
|
+
return s.replace(/\/+$/, '');
|
|
50
|
+
}
|
|
51
|
+
function bad(name, why) {
|
|
52
|
+
// Never echo the value: it carries the secret.
|
|
53
|
+
return new StorageError('not_configured', `${name} is malformed: ${why}`);
|
|
54
|
+
}
|
|
55
|
+
export function encodeBrokeredBinding(b) {
|
|
56
|
+
const service = new URL(b.service);
|
|
57
|
+
if (service.protocol !== 'https:' && service.protocol !== 'http:') {
|
|
58
|
+
throw new StorageError('invalid', `service must be http(s): ${b.service}`);
|
|
59
|
+
}
|
|
60
|
+
const query = new URLSearchParams();
|
|
61
|
+
if (b.store === 'public')
|
|
62
|
+
query.set('store', 'public');
|
|
63
|
+
if (b.publicBase)
|
|
64
|
+
query.set('base', trimSlash(b.publicBase));
|
|
65
|
+
const q = query.toString();
|
|
66
|
+
return (`${service.protocol}//${encodeURIComponent(b.instance)}:${encodeURIComponent(b.secret)}@` +
|
|
67
|
+
`${service.host}/${q ? `?${q}` : ''}`);
|
|
68
|
+
}
|
|
69
|
+
/** Either form; the scheme says which. */
|
|
70
|
+
export function parseBinding(value, name = STORAGE_ENV) {
|
|
71
|
+
const v = value.trim();
|
|
72
|
+
if (/^s3\+https?:/.test(v))
|
|
73
|
+
return { kind: 'static', binding: decodeStorageBinding(v, name) };
|
|
74
|
+
let url;
|
|
75
|
+
try {
|
|
76
|
+
url = new URL(v);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw bad(name, 'not a URL');
|
|
80
|
+
}
|
|
81
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
82
|
+
throw bad(name, 'the scheme is s3+https (static) or https (the storage service)');
|
|
83
|
+
}
|
|
84
|
+
if (!url.username || !url.password)
|
|
85
|
+
throw bad(name, 'the instance id and secret are missing');
|
|
86
|
+
const base = url.searchParams.get('base');
|
|
87
|
+
return {
|
|
88
|
+
kind: 'brokered',
|
|
89
|
+
binding: {
|
|
90
|
+
service: `${url.protocol}//${url.host}`,
|
|
91
|
+
instance: decodeURIComponent(url.username),
|
|
92
|
+
secret: decodeURIComponent(url.password),
|
|
93
|
+
store: url.searchParams.get('store') === 'public' ? 'public' : 'private',
|
|
94
|
+
...(base ? { publicBase: trimSlash(base) } : {}),
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BrokeredBinding, IssuedCredentials } from './binding.js';
|
|
2
|
+
import { type S3StoreOptions } from './s3.js';
|
|
3
|
+
import type { PublicStore, Store } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* A store whose credentials come from the storage service (storage plan §8):
|
|
6
|
+
* the binding holds only the instance's id and secret, and the service signs
|
|
7
|
+
* an hour of R2 temporary credentials for this instance's bucket on request.
|
|
8
|
+
* They are cached and refreshed five minutes before they expire, one exchange
|
|
9
|
+
* at a time, so a busy process asks about once an hour and every call after
|
|
10
|
+
* that goes straight to R2.
|
|
11
|
+
*
|
|
12
|
+
* A signed URL carries the session token, so it stops working when the
|
|
13
|
+
* credentials that signed it expire. Signing therefore uses credentials that
|
|
14
|
+
* outlive the URL, exchanging early if the cached ones don't, and refuses a
|
|
15
|
+
* lifetime no credentials from the service can cover.
|
|
16
|
+
*/
|
|
17
|
+
export type { IssuedCredentials };
|
|
18
|
+
export declare function createBrokeredStore(b: BrokeredBinding & {
|
|
19
|
+
store: 'public';
|
|
20
|
+
}, options?: S3StoreOptions): PublicStore;
|
|
21
|
+
export declare function createBrokeredStore(b: BrokeredBinding, options?: S3StoreOptions): Store;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { StorageError } from './errors.js';
|
|
2
|
+
import { createS3Store } from './s3.js';
|
|
3
|
+
import { checkExpiresIn, checkKey, DOWNLOAD_EXPIRES_IN, UPLOAD_EXPIRES_IN } from './shared.js';
|
|
4
|
+
import { encodeKey } from './sigv4.js';
|
|
5
|
+
const REFRESH_EARLY_MS = 5 * 60_000;
|
|
6
|
+
export function createBrokeredStore(b, options = {}) {
|
|
7
|
+
const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
8
|
+
let cached;
|
|
9
|
+
let inflight;
|
|
10
|
+
async function exchange() {
|
|
11
|
+
let res;
|
|
12
|
+
try {
|
|
13
|
+
res = await doFetch(`${b.service}/v1/credentials`, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: {
|
|
16
|
+
authorization: `Basic ${btoa(`${b.instance}:${b.secret}`)}`,
|
|
17
|
+
'content-type': 'application/json',
|
|
18
|
+
},
|
|
19
|
+
body: JSON.stringify({ store: b.store }),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
throw new StorageError('unavailable', `the storage service is unreachable: ${e.message}`);
|
|
24
|
+
}
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const why = (await res.text().catch(() => '')).slice(0, 200);
|
|
27
|
+
if (res.status === 401 || res.status === 403) {
|
|
28
|
+
throw new StorageError('forbidden', `the storage service refused this instance: ${why}`, res.status);
|
|
29
|
+
}
|
|
30
|
+
if (res.status === 404 || res.status === 410) {
|
|
31
|
+
throw new StorageError('not_configured', `the storage service has no such store: ${why}`, res.status);
|
|
32
|
+
}
|
|
33
|
+
throw new StorageError('unavailable', `the storage service answered ${res.status}: ${why}`, res.status);
|
|
34
|
+
}
|
|
35
|
+
const c = (await res.json());
|
|
36
|
+
const store = createS3Store({
|
|
37
|
+
endpoint: c.endpoint,
|
|
38
|
+
bucket: c.bucket,
|
|
39
|
+
region: c.region,
|
|
40
|
+
accessKeyId: c.accessKeyId,
|
|
41
|
+
secretAccessKey: c.secretAccessKey,
|
|
42
|
+
...(b.publicBase ? { publicBase: b.publicBase } : {}),
|
|
43
|
+
}, { ...options, sessionToken: c.sessionToken });
|
|
44
|
+
const expiresAt = Date.parse(c.expiresAt);
|
|
45
|
+
cached = { store, refreshAt: expiresAt - REFRESH_EARLY_MS, expiresAt };
|
|
46
|
+
return store;
|
|
47
|
+
}
|
|
48
|
+
async function current() {
|
|
49
|
+
if (cached && Date.now() < cached.refreshAt)
|
|
50
|
+
return cached.store;
|
|
51
|
+
inflight ??= exchange().finally(() => {
|
|
52
|
+
inflight = undefined;
|
|
53
|
+
});
|
|
54
|
+
return inflight;
|
|
55
|
+
}
|
|
56
|
+
/** A store whose credentials last at least `seconds` more. */
|
|
57
|
+
async function lasting(seconds) {
|
|
58
|
+
// A function, not a narrowed read: `cached` changes across the awaits.
|
|
59
|
+
const leftMs = () => (cached ? cached.expiresAt - Date.now() : 0);
|
|
60
|
+
let s = await current();
|
|
61
|
+
if (leftMs() >= seconds * 1000)
|
|
62
|
+
return s;
|
|
63
|
+
cached = undefined;
|
|
64
|
+
s = await current();
|
|
65
|
+
if (leftMs() >= seconds * 1000)
|
|
66
|
+
return s;
|
|
67
|
+
throw new StorageError('invalid', `expiresIn is at most ${Math.max(0, Math.floor(leftMs() / 1000))} seconds here: a ` +
|
|
68
|
+
'signed URL stops working when the storage service’s short-lived credentials that ' +
|
|
69
|
+
'signed it expire');
|
|
70
|
+
}
|
|
71
|
+
const store = {
|
|
72
|
+
put: async (...a) => (await current()).put(...a),
|
|
73
|
+
get: async (...a) => (await current()).get(...a),
|
|
74
|
+
head: async (...a) => (await current()).head(...a),
|
|
75
|
+
delete: async (...a) => (await current()).delete(...a),
|
|
76
|
+
list: async (...a) => (await current()).list(...a),
|
|
77
|
+
signUpload: async (key, o) => (await lasting(checkExpiresIn(o.expiresIn, UPLOAD_EXPIRES_IN))).signUpload(key, o),
|
|
78
|
+
signDownload: async (key, o = {}) => (await lasting(checkExpiresIn(o.expiresIn, DOWNLOAD_EXPIRES_IN))).signDownload(key, o),
|
|
79
|
+
upload: async (...a) => (await current()).upload(...a),
|
|
80
|
+
};
|
|
81
|
+
if (b.store !== 'public')
|
|
82
|
+
return store;
|
|
83
|
+
if (!b.publicBase)
|
|
84
|
+
throw new StorageError('not_configured', 'a public store binding has no base URL');
|
|
85
|
+
const base = b.publicBase;
|
|
86
|
+
// url() needs no credentials: the public origin plus the encoded key, as the static store builds it.
|
|
87
|
+
return {
|
|
88
|
+
...store,
|
|
89
|
+
url: (key) => {
|
|
90
|
+
checkKey(key);
|
|
91
|
+
return `${base}/${encodeKey(key)}`;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -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,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;
|