@broberg/media 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/README.md +60 -0
- package/dist/index.cjs +58 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +54 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @broberg/media
|
|
2
|
+
|
|
3
|
+
The fleet's **provider-agnostic media-storage facade** — one `createMedia()` API
|
|
4
|
+
(`upload` · `signedUrl` · `delete`) over swappable storage providers, so a later
|
|
5
|
+
move between backends never touches a call-site (the `@broberg/ai-sdk` pattern,
|
|
6
|
+
for object storage). Ships with **Cloudflare R2**; the config grows to S3 /
|
|
7
|
+
Supabase / GCS without changing your code.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i @broberg/media
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The R2 provider speaks the S3 API and signs with [`aws4fetch`](https://github.com/mhart/aws4fetch)
|
|
14
|
+
(tiny, zero-dep, runs in Node · Bun · edge / Workers) — no AWS SDK.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { createMedia } from "@broberg/media";
|
|
20
|
+
|
|
21
|
+
const media = createMedia({
|
|
22
|
+
provider: "r2",
|
|
23
|
+
accountId: process.env.R2_ACCOUNT_ID!,
|
|
24
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
|
25
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
|
26
|
+
bucket: "assets",
|
|
27
|
+
jurisdiction: "eu", // pin EU data-residency (GDPR); must match how the bucket was created
|
|
28
|
+
keyPrefix: "tenants/acme/", // optional multi-tenant isolation
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await media.upload("logo.png", bytes, { contentType: "image/png" });
|
|
32
|
+
const url = await media.signedUrl("logo.png", { expiresIn: 600 }); // presigned GET, no public bucket
|
|
33
|
+
await media.delete("logo.png"); // idempotent — a missing key is not an error
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
| Method | Returns | Notes |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `upload(key, body, opts?)` | `{ key }` | `body` = anything `fetch` accepts; `opts.contentType` / `opts.cacheControl` |
|
|
41
|
+
| `signedUrl(key, opts?)` | `string` | time-limited presigned GET (`opts.expiresIn` seconds, default 3600) |
|
|
42
|
+
| `delete(key)` | `void` | idempotent (404 tolerated) |
|
|
43
|
+
|
|
44
|
+
`keyPrefix` is prepended to every key (with a single `/`); leading slashes on
|
|
45
|
+
keys are stripped, so `keyPrefix:"tenants/acme"` + `"/logo.png"` →
|
|
46
|
+
`tenants/acme/logo.png`.
|
|
47
|
+
|
|
48
|
+
## Provisioning the bucket
|
|
49
|
+
|
|
50
|
+
This package **consumes** an existing bucket + S3 creds. To create an
|
|
51
|
+
EU-jurisdiction R2 bucket + scoped creds 100% programmatically (no dashboard),
|
|
52
|
+
use the fleet's `dns-mcp` R2Client / MCP tools (`r2_create_bucket`,
|
|
53
|
+
`r2_create_scoped_token`) — see the **Cloudflare** card on
|
|
54
|
+
[discovery.broberg.ai](https://discovery.broberg.ai/api/infra/cloudflare).
|
|
55
|
+
EU jurisdiction is set at creation and is immutable.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
Part of the [broberg.ai shared inventory](https://discovery.broberg.ai). Search
|
|
60
|
+
before you build: `GET https://discovery.broberg.ai/api/search?q=storage`.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var aws4fetch = require('aws4fetch');
|
|
4
|
+
|
|
5
|
+
// src/providers/r2.ts
|
|
6
|
+
var r2Host = (accountId, jurisdiction) => jurisdiction === "eu" ? `${accountId}.eu.r2.cloudflarestorage.com` : `${accountId}.r2.cloudflarestorage.com`;
|
|
7
|
+
var encodeKey = (key) => key.split("/").map(encodeURIComponent).join("/");
|
|
8
|
+
function createR2Store(cfg) {
|
|
9
|
+
const base = `https://${r2Host(cfg.accountId, cfg.jurisdiction)}/${cfg.bucket}`;
|
|
10
|
+
const prefix = cfg.keyPrefix ? `${cfg.keyPrefix.replace(/\/+$/, "")}/` : "";
|
|
11
|
+
const aws = new aws4fetch.AwsClient({
|
|
12
|
+
accessKeyId: cfg.accessKeyId,
|
|
13
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
14
|
+
region: "auto",
|
|
15
|
+
service: "s3",
|
|
16
|
+
retries: 2
|
|
17
|
+
// modest resilience against R2's transient 5xx/429 (aws4fetch defaults to 10)
|
|
18
|
+
});
|
|
19
|
+
const fullKey = (key) => prefix + key.replace(/^\/+/, "");
|
|
20
|
+
const objectUrl = (key) => `${base}/${encodeKey(fullKey(key))}`;
|
|
21
|
+
return {
|
|
22
|
+
async upload(key, body, opts) {
|
|
23
|
+
const headers = {};
|
|
24
|
+
if (opts?.contentType) headers["content-type"] = opts.contentType;
|
|
25
|
+
if (opts?.cacheControl) headers["cache-control"] = opts.cacheControl;
|
|
26
|
+
const res = await aws.fetch(objectUrl(key), { method: "PUT", body, headers });
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
throw new Error(`media(r2): upload failed ${res.status} ${await res.text().catch(() => "")}`.trim());
|
|
29
|
+
}
|
|
30
|
+
return { key: fullKey(key) };
|
|
31
|
+
},
|
|
32
|
+
async signedUrl(key, opts) {
|
|
33
|
+
const url = `${objectUrl(key)}?X-Amz-Expires=${opts?.expiresIn ?? 3600}`;
|
|
34
|
+
const signed = await aws.sign(url, { method: "GET", aws: { signQuery: true } });
|
|
35
|
+
return signed.url;
|
|
36
|
+
},
|
|
37
|
+
async delete(key) {
|
|
38
|
+
const res = await aws.fetch(objectUrl(key), { method: "DELETE" });
|
|
39
|
+
if (!res.ok && res.status !== 404) {
|
|
40
|
+
throw new Error(`media(r2): delete failed ${res.status}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/index.ts
|
|
47
|
+
function createMedia(config) {
|
|
48
|
+
switch (config.provider) {
|
|
49
|
+
case "r2":
|
|
50
|
+
return createR2Store(config);
|
|
51
|
+
default:
|
|
52
|
+
throw new Error(`media: unknown provider "${config.provider}"`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
exports.createMedia = createMedia;
|
|
57
|
+
//# sourceMappingURL=index.cjs.map
|
|
58
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/providers/r2.ts","../src/index.ts"],"names":["AwsClient"],"mappings":"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","file":"index.cjs","sourcesContent":["// Cloudflare R2 provider. R2 speaks the S3 API, so this is a thin SigV4 layer\n// over aws4fetch (tiny, zero-dep, runs in Node/Bun/edge/Workers). No AWS SDK.\nimport { AwsClient } from \"aws4fetch\";\nimport type { MediaBody, MediaStore, R2Config, SignedUrlOptions, UploadOptions } from \"../types\";\n\n// R2's S3 endpoint host. The EU jurisdiction pins data-residency and MUST match\n// how the bucket was created (jurisdiction is immutable at creation).\nconst r2Host = (accountId: string, jurisdiction?: string) =>\n jurisdiction === \"eu\"\n ? `${accountId}.eu.r2.cloudflarestorage.com`\n : `${accountId}.r2.cloudflarestorage.com`;\n\n// Encode each path segment but keep the \"/\" separators intact.\nconst encodeKey = (key: string) =>\n key.split(\"/\").map(encodeURIComponent).join(\"/\");\n\nexport function createR2Store(cfg: R2Config): MediaStore {\n const base = `https://${r2Host(cfg.accountId, cfg.jurisdiction)}/${cfg.bucket}`;\n const prefix = cfg.keyPrefix ? `${cfg.keyPrefix.replace(/\\/+$/, \"\")}/` : \"\";\n // R2 always uses region \"auto\"; the S3 service signs the request.\n const aws = new AwsClient({\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n region: \"auto\",\n service: \"s3\",\n retries: 2, // modest resilience against R2's transient 5xx/429 (aws4fetch defaults to 10)\n });\n\n const fullKey = (key: string) => prefix + key.replace(/^\\/+/, \"\");\n const objectUrl = (key: string) => `${base}/${encodeKey(fullKey(key))}`;\n\n return {\n async upload(key: string, body: MediaBody, opts?: UploadOptions) {\n const headers: Record<string, string> = {};\n if (opts?.contentType) headers[\"content-type\"] = opts.contentType;\n if (opts?.cacheControl) headers[\"cache-control\"] = opts.cacheControl;\n const res = await aws.fetch(objectUrl(key), { method: \"PUT\", body: body as BodyInit, headers });\n if (!res.ok) {\n throw new Error(`media(r2): upload failed ${res.status} ${await res.text().catch(() => \"\")}`.trim());\n }\n return { key: fullKey(key) };\n },\n\n async signedUrl(key: string, opts?: SignedUrlOptions) {\n const url = `${objectUrl(key)}?X-Amz-Expires=${opts?.expiresIn ?? 3600}`;\n const signed = await aws.sign(url, { method: \"GET\", aws: { signQuery: true } });\n return signed.url;\n },\n\n async delete(key: string) {\n const res = await aws.fetch(objectUrl(key), { method: \"DELETE\" });\n if (!res.ok && res.status !== 404) {\n throw new Error(`media(r2): delete failed ${res.status}`);\n }\n },\n };\n}\n","// @broberg/media — the fleet's provider-agnostic media-storage facade (F006).\n// One API (upload · signedUrl · delete) over swappable storage providers, so a\n// later move between backends never touches a call-site. Ships with Cloudflare\n// R2; the config union grows as providers are added (s3, supabase, gcs …).\nimport { createR2Store } from \"./providers/r2\";\nimport type { MediaConfig, MediaStore } from \"./types\";\n\nexport type {\n MediaBody,\n MediaConfig,\n MediaStore,\n R2Config,\n SignedUrlOptions,\n UploadOptions,\n} from \"./types\";\n\n/**\n * Create a media store for the configured provider. The returned {@link MediaStore}\n * is identical across providers — swap `provider` and your call-sites don't change.\n *\n * @example\n * const media = createMedia({\n * provider: \"r2\",\n * accountId, accessKeyId, secretAccessKey, bucket: \"assets\",\n * jurisdiction: \"eu\", keyPrefix: \"tenants/acme/\",\n * });\n * await media.upload(\"logo.png\", bytes, { contentType: \"image/png\" });\n * const url = await media.signedUrl(\"logo.png\", { expiresIn: 600 });\n */\nexport function createMedia(config: MediaConfig): MediaStore {\n switch (config.provider) {\n case \"r2\":\n return createR2Store(config);\n default:\n throw new Error(`media: unknown provider \"${(config as { provider?: string }).provider}\"`);\n }\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** A binary payload to store. Anything fetch() accepts as a body. */
|
|
2
|
+
type MediaBody = Uint8Array | ArrayBuffer | Blob | string | ReadableStream;
|
|
3
|
+
interface UploadOptions {
|
|
4
|
+
/** MIME type stored on the object (e.g. "image/png"). */
|
|
5
|
+
contentType?: string;
|
|
6
|
+
/** Cache-Control header stored on the object. */
|
|
7
|
+
cacheControl?: string;
|
|
8
|
+
}
|
|
9
|
+
interface SignedUrlOptions {
|
|
10
|
+
/** Seconds the presigned GET URL stays valid (default 3600). */
|
|
11
|
+
expiresIn?: number;
|
|
12
|
+
}
|
|
13
|
+
/** The uniform surface every provider implements. */
|
|
14
|
+
interface MediaStore {
|
|
15
|
+
/** Store an object at `key`; returns the final (prefixed) key. */
|
|
16
|
+
upload(key: string, body: MediaBody, opts?: UploadOptions): Promise<{
|
|
17
|
+
key: string;
|
|
18
|
+
}>;
|
|
19
|
+
/** A time-limited presigned GET URL for `key` (no public bucket needed). */
|
|
20
|
+
signedUrl(key: string, opts?: SignedUrlOptions): Promise<string>;
|
|
21
|
+
/** Delete the object at `key` (idempotent — a missing key is not an error). */
|
|
22
|
+
delete(key: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** Cloudflare R2 provider config (S3-compatible). */
|
|
25
|
+
interface R2Config {
|
|
26
|
+
provider: "r2";
|
|
27
|
+
accountId: string;
|
|
28
|
+
accessKeyId: string;
|
|
29
|
+
secretAccessKey: string;
|
|
30
|
+
bucket: string;
|
|
31
|
+
/** R2 jurisdiction — "eu" pins EU data-residency (GDPR); default otherwise. */
|
|
32
|
+
jurisdiction?: "default" | "eu";
|
|
33
|
+
/** Optional key prefix prepended to every key (e.g. "tenants/acme/") for multi-tenant isolation. */
|
|
34
|
+
keyPrefix?: string;
|
|
35
|
+
}
|
|
36
|
+
/** The config union — grows as providers are added (s3, supabase, gcs …). */
|
|
37
|
+
type MediaConfig = R2Config;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a media store for the configured provider. The returned {@link MediaStore}
|
|
41
|
+
* is identical across providers — swap `provider` and your call-sites don't change.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const media = createMedia({
|
|
45
|
+
* provider: "r2",
|
|
46
|
+
* accountId, accessKeyId, secretAccessKey, bucket: "assets",
|
|
47
|
+
* jurisdiction: "eu", keyPrefix: "tenants/acme/",
|
|
48
|
+
* });
|
|
49
|
+
* await media.upload("logo.png", bytes, { contentType: "image/png" });
|
|
50
|
+
* const url = await media.signedUrl("logo.png", { expiresIn: 600 });
|
|
51
|
+
*/
|
|
52
|
+
declare function createMedia(config: MediaConfig): MediaStore;
|
|
53
|
+
|
|
54
|
+
export { type MediaBody, type MediaConfig, type MediaStore, type R2Config, type SignedUrlOptions, type UploadOptions, createMedia };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** A binary payload to store. Anything fetch() accepts as a body. */
|
|
2
|
+
type MediaBody = Uint8Array | ArrayBuffer | Blob | string | ReadableStream;
|
|
3
|
+
interface UploadOptions {
|
|
4
|
+
/** MIME type stored on the object (e.g. "image/png"). */
|
|
5
|
+
contentType?: string;
|
|
6
|
+
/** Cache-Control header stored on the object. */
|
|
7
|
+
cacheControl?: string;
|
|
8
|
+
}
|
|
9
|
+
interface SignedUrlOptions {
|
|
10
|
+
/** Seconds the presigned GET URL stays valid (default 3600). */
|
|
11
|
+
expiresIn?: number;
|
|
12
|
+
}
|
|
13
|
+
/** The uniform surface every provider implements. */
|
|
14
|
+
interface MediaStore {
|
|
15
|
+
/** Store an object at `key`; returns the final (prefixed) key. */
|
|
16
|
+
upload(key: string, body: MediaBody, opts?: UploadOptions): Promise<{
|
|
17
|
+
key: string;
|
|
18
|
+
}>;
|
|
19
|
+
/** A time-limited presigned GET URL for `key` (no public bucket needed). */
|
|
20
|
+
signedUrl(key: string, opts?: SignedUrlOptions): Promise<string>;
|
|
21
|
+
/** Delete the object at `key` (idempotent — a missing key is not an error). */
|
|
22
|
+
delete(key: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** Cloudflare R2 provider config (S3-compatible). */
|
|
25
|
+
interface R2Config {
|
|
26
|
+
provider: "r2";
|
|
27
|
+
accountId: string;
|
|
28
|
+
accessKeyId: string;
|
|
29
|
+
secretAccessKey: string;
|
|
30
|
+
bucket: string;
|
|
31
|
+
/** R2 jurisdiction — "eu" pins EU data-residency (GDPR); default otherwise. */
|
|
32
|
+
jurisdiction?: "default" | "eu";
|
|
33
|
+
/** Optional key prefix prepended to every key (e.g. "tenants/acme/") for multi-tenant isolation. */
|
|
34
|
+
keyPrefix?: string;
|
|
35
|
+
}
|
|
36
|
+
/** The config union — grows as providers are added (s3, supabase, gcs …). */
|
|
37
|
+
type MediaConfig = R2Config;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a media store for the configured provider. The returned {@link MediaStore}
|
|
41
|
+
* is identical across providers — swap `provider` and your call-sites don't change.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const media = createMedia({
|
|
45
|
+
* provider: "r2",
|
|
46
|
+
* accountId, accessKeyId, secretAccessKey, bucket: "assets",
|
|
47
|
+
* jurisdiction: "eu", keyPrefix: "tenants/acme/",
|
|
48
|
+
* });
|
|
49
|
+
* await media.upload("logo.png", bytes, { contentType: "image/png" });
|
|
50
|
+
* const url = await media.signedUrl("logo.png", { expiresIn: 600 });
|
|
51
|
+
*/
|
|
52
|
+
declare function createMedia(config: MediaConfig): MediaStore;
|
|
53
|
+
|
|
54
|
+
export { type MediaBody, type MediaConfig, type MediaStore, type R2Config, type SignedUrlOptions, type UploadOptions, createMedia };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AwsClient } from 'aws4fetch';
|
|
2
|
+
|
|
3
|
+
// src/providers/r2.ts
|
|
4
|
+
var r2Host = (accountId, jurisdiction) => jurisdiction === "eu" ? `${accountId}.eu.r2.cloudflarestorage.com` : `${accountId}.r2.cloudflarestorage.com`;
|
|
5
|
+
var encodeKey = (key) => key.split("/").map(encodeURIComponent).join("/");
|
|
6
|
+
function createR2Store(cfg) {
|
|
7
|
+
const base = `https://${r2Host(cfg.accountId, cfg.jurisdiction)}/${cfg.bucket}`;
|
|
8
|
+
const prefix = cfg.keyPrefix ? `${cfg.keyPrefix.replace(/\/+$/, "")}/` : "";
|
|
9
|
+
const aws = new AwsClient({
|
|
10
|
+
accessKeyId: cfg.accessKeyId,
|
|
11
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
12
|
+
region: "auto",
|
|
13
|
+
service: "s3",
|
|
14
|
+
retries: 2
|
|
15
|
+
// modest resilience against R2's transient 5xx/429 (aws4fetch defaults to 10)
|
|
16
|
+
});
|
|
17
|
+
const fullKey = (key) => prefix + key.replace(/^\/+/, "");
|
|
18
|
+
const objectUrl = (key) => `${base}/${encodeKey(fullKey(key))}`;
|
|
19
|
+
return {
|
|
20
|
+
async upload(key, body, opts) {
|
|
21
|
+
const headers = {};
|
|
22
|
+
if (opts?.contentType) headers["content-type"] = opts.contentType;
|
|
23
|
+
if (opts?.cacheControl) headers["cache-control"] = opts.cacheControl;
|
|
24
|
+
const res = await aws.fetch(objectUrl(key), { method: "PUT", body, headers });
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
throw new Error(`media(r2): upload failed ${res.status} ${await res.text().catch(() => "")}`.trim());
|
|
27
|
+
}
|
|
28
|
+
return { key: fullKey(key) };
|
|
29
|
+
},
|
|
30
|
+
async signedUrl(key, opts) {
|
|
31
|
+
const url = `${objectUrl(key)}?X-Amz-Expires=${opts?.expiresIn ?? 3600}`;
|
|
32
|
+
const signed = await aws.sign(url, { method: "GET", aws: { signQuery: true } });
|
|
33
|
+
return signed.url;
|
|
34
|
+
},
|
|
35
|
+
async delete(key) {
|
|
36
|
+
const res = await aws.fetch(objectUrl(key), { method: "DELETE" });
|
|
37
|
+
if (!res.ok && res.status !== 404) {
|
|
38
|
+
throw new Error(`media(r2): delete failed ${res.status}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/index.ts
|
|
45
|
+
function createMedia(config) {
|
|
46
|
+
switch (config.provider) {
|
|
47
|
+
case "r2":
|
|
48
|
+
return createR2Store(config);
|
|
49
|
+
default:
|
|
50
|
+
throw new Error(`media: unknown provider "${config.provider}"`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { createMedia };
|
|
55
|
+
//# sourceMappingURL=index.js.map
|
|
56
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/providers/r2.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// Cloudflare R2 provider. R2 speaks the S3 API, so this is a thin SigV4 layer\n// over aws4fetch (tiny, zero-dep, runs in Node/Bun/edge/Workers). No AWS SDK.\nimport { AwsClient } from \"aws4fetch\";\nimport type { MediaBody, MediaStore, R2Config, SignedUrlOptions, UploadOptions } from \"../types\";\n\n// R2's S3 endpoint host. The EU jurisdiction pins data-residency and MUST match\n// how the bucket was created (jurisdiction is immutable at creation).\nconst r2Host = (accountId: string, jurisdiction?: string) =>\n jurisdiction === \"eu\"\n ? `${accountId}.eu.r2.cloudflarestorage.com`\n : `${accountId}.r2.cloudflarestorage.com`;\n\n// Encode each path segment but keep the \"/\" separators intact.\nconst encodeKey = (key: string) =>\n key.split(\"/\").map(encodeURIComponent).join(\"/\");\n\nexport function createR2Store(cfg: R2Config): MediaStore {\n const base = `https://${r2Host(cfg.accountId, cfg.jurisdiction)}/${cfg.bucket}`;\n const prefix = cfg.keyPrefix ? `${cfg.keyPrefix.replace(/\\/+$/, \"\")}/` : \"\";\n // R2 always uses region \"auto\"; the S3 service signs the request.\n const aws = new AwsClient({\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n region: \"auto\",\n service: \"s3\",\n retries: 2, // modest resilience against R2's transient 5xx/429 (aws4fetch defaults to 10)\n });\n\n const fullKey = (key: string) => prefix + key.replace(/^\\/+/, \"\");\n const objectUrl = (key: string) => `${base}/${encodeKey(fullKey(key))}`;\n\n return {\n async upload(key: string, body: MediaBody, opts?: UploadOptions) {\n const headers: Record<string, string> = {};\n if (opts?.contentType) headers[\"content-type\"] = opts.contentType;\n if (opts?.cacheControl) headers[\"cache-control\"] = opts.cacheControl;\n const res = await aws.fetch(objectUrl(key), { method: \"PUT\", body: body as BodyInit, headers });\n if (!res.ok) {\n throw new Error(`media(r2): upload failed ${res.status} ${await res.text().catch(() => \"\")}`.trim());\n }\n return { key: fullKey(key) };\n },\n\n async signedUrl(key: string, opts?: SignedUrlOptions) {\n const url = `${objectUrl(key)}?X-Amz-Expires=${opts?.expiresIn ?? 3600}`;\n const signed = await aws.sign(url, { method: \"GET\", aws: { signQuery: true } });\n return signed.url;\n },\n\n async delete(key: string) {\n const res = await aws.fetch(objectUrl(key), { method: \"DELETE\" });\n if (!res.ok && res.status !== 404) {\n throw new Error(`media(r2): delete failed ${res.status}`);\n }\n },\n };\n}\n","// @broberg/media — the fleet's provider-agnostic media-storage facade (F006).\n// One API (upload · signedUrl · delete) over swappable storage providers, so a\n// later move between backends never touches a call-site. Ships with Cloudflare\n// R2; the config union grows as providers are added (s3, supabase, gcs …).\nimport { createR2Store } from \"./providers/r2\";\nimport type { MediaConfig, MediaStore } from \"./types\";\n\nexport type {\n MediaBody,\n MediaConfig,\n MediaStore,\n R2Config,\n SignedUrlOptions,\n UploadOptions,\n} from \"./types\";\n\n/**\n * Create a media store for the configured provider. The returned {@link MediaStore}\n * is identical across providers — swap `provider` and your call-sites don't change.\n *\n * @example\n * const media = createMedia({\n * provider: \"r2\",\n * accountId, accessKeyId, secretAccessKey, bucket: \"assets\",\n * jurisdiction: \"eu\", keyPrefix: \"tenants/acme/\",\n * });\n * await media.upload(\"logo.png\", bytes, { contentType: \"image/png\" });\n * const url = await media.signedUrl(\"logo.png\", { expiresIn: 600 });\n */\nexport function createMedia(config: MediaConfig): MediaStore {\n switch (config.provider) {\n case \"r2\":\n return createR2Store(config);\n default:\n throw new Error(`media: unknown provider \"${(config as { provider?: string }).provider}\"`);\n }\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@broberg/media",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The fleet's provider-agnostic media-storage facade — one createMedia() API (upload · signedUrl · delete) over swappable storage providers, so a later move between providers never touches a call-site. Ships with a Cloudflare R2 provider (S3-compatible, SigV4 via aws4fetch — Node/Bun/edge), multi-tenant keyPrefix and EU-jurisdiction support. Start on R2, add S3/Supabase later without changing your code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": ["dist", "README.md"],
|
|
9
|
+
"main": "./dist/index.cjs",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.cjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"aws4fetch": "^1.0.20"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.0.0",
|
|
29
|
+
"tsup": "^8.3.0",
|
|
30
|
+
"typescript": "^5.6.0",
|
|
31
|
+
"vitest": "^2.1.0"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"media",
|
|
35
|
+
"storage",
|
|
36
|
+
"object-storage",
|
|
37
|
+
"r2",
|
|
38
|
+
"cloudflare",
|
|
39
|
+
"s3",
|
|
40
|
+
"upload",
|
|
41
|
+
"signed-url",
|
|
42
|
+
"broberg"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/broberg-ai/components",
|
|
47
|
+
"directory": "packages/media"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": { "access": "public" }
|
|
50
|
+
}
|