@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 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"]}
@@ -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 };
@@ -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
+ }