@nymbalhq/media-s3 0.0.0-stage → 1.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.
@@ -0,0 +1,40 @@
1
+ import { MediaStorage, AdapterContext } from '@nymbalhq/types';
2
+
3
+ /** Where the URL of an object starts, and where its key starts in the bucket. */
4
+ interface S3ObjectUrlOptions {
5
+ bucket: string;
6
+ /** `us-east-1` when not given. */
7
+ region?: string;
8
+ /** An S3-compatible API (MinIO, R2); its objects are served path-style under it. */
9
+ endpoint?: string;
10
+ /** Prepended to every key, its edge slashes trimmed; none when empty. */
11
+ prefix?: string;
12
+ /** Where the bucket's objects are served from (a CDN), when not the bucket's own URL. */
13
+ publicUrl?: string;
14
+ }
15
+ interface S3MediaStorageOptions extends S3ObjectUrlOptions {
16
+ /** True by default when an endpoint is set: an S3-compatible server rarely has per-bucket hostnames. */
17
+ forcePathStyle?: boolean;
18
+ /** For tests only: a store gets its credentials from the AWS SDK's default chain. */
19
+ credentials?: {
20
+ accessKeyId: string;
21
+ secretAccessKey: string;
22
+ };
23
+ }
24
+ declare const S3_MEDIA_CAPABILITIES: readonly string[];
25
+ declare const DEFAULT_S3_REGION = "us-east-1";
26
+ /** The public URL of the object stored under `key`: the base, then each segment of the object key percent-encoded. */
27
+ declare function s3ObjectUrl(options: S3ObjectUrlOptions, key: string): string;
28
+ /**
29
+ * Media stored as objects in a bucket the operator owns: it never creates buckets or sets policies, so the bucket (or
30
+ * the `publicUrl` in front of it) must already serve its objects publicly.
31
+ */
32
+ declare function createS3MediaStorage(options: S3MediaStorageOptions): MediaStorage;
33
+
34
+ /**
35
+ * The kernel's entry: `infrastructure.media: 's3'`, with the `s3` block as the platform resolved it (its environment
36
+ * fallbacks and defaults applied). Credentials are never in it: the AWS SDK's default chain provides them.
37
+ */
38
+ declare function createAdapter(ctx: AdapterContext): MediaStorage;
39
+
40
+ export { DEFAULT_S3_REGION, type S3MediaStorageOptions, type S3ObjectUrlOptions, S3_MEDIA_CAPABILITIES, createAdapter, createS3MediaStorage, s3ObjectUrl };
package/dist/index.js ADDED
@@ -0,0 +1,98 @@
1
+ // src/index.ts
2
+ import { ConfigError } from "@nymbalhq/types";
3
+
4
+ // src/s3-media-storage.ts
5
+ import { HeadBucketCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
6
+ import { boundedHealthCheck } from "@nymbalhq/types";
7
+ var S3_MEDIA_CAPABILITIES = ["put"];
8
+ var DEFAULT_S3_REGION = "us-east-1";
9
+ var trimTrailingSlashes = (s) => s.replace(/\/+$/, "");
10
+ function objectKey(prefix, key) {
11
+ const trimmed = (prefix ?? "").replace(/^\/+|\/+$/g, "");
12
+ return trimmed === "" ? key : `${trimmed}/${key}`;
13
+ }
14
+ function baseUrl(options) {
15
+ if (options.publicUrl !== void 0) return trimTrailingSlashes(options.publicUrl);
16
+ if (options.endpoint !== void 0) {
17
+ return `${trimTrailingSlashes(options.endpoint)}/${options.bucket}`;
18
+ }
19
+ return `https://${options.bucket}.s3.${options.region ?? DEFAULT_S3_REGION}.amazonaws.com`;
20
+ }
21
+ function s3ObjectUrl(options, key) {
22
+ const path = objectKey(options.prefix, key).split("/").map(encodeURIComponent).join("/");
23
+ return `${baseUrl(options)}/${path}`;
24
+ }
25
+ function createS3MediaStorage(options) {
26
+ const client = new S3Client({
27
+ region: options.region ?? DEFAULT_S3_REGION,
28
+ forcePathStyle: options.forcePathStyle ?? options.endpoint !== void 0,
29
+ ...options.endpoint !== void 0 && { endpoint: options.endpoint },
30
+ ...options.credentials !== void 0 && { credentials: options.credentials }
31
+ });
32
+ return {
33
+ capabilities: () => S3_MEDIA_CAPABILITIES,
34
+ async put(key, body, contentType) {
35
+ await client.send(
36
+ new PutObjectCommand({
37
+ Bucket: options.bucket,
38
+ Key: objectKey(options.prefix, key),
39
+ Body: body,
40
+ ContentType: contentType
41
+ })
42
+ );
43
+ return { url: s3ObjectUrl(options, key) };
44
+ },
45
+ // HeadBucket reads the bucket's existence and our access to it; aborted at the bound, retries included.
46
+ healthCheck() {
47
+ return boundedHealthCheck(
48
+ async (signal) => {
49
+ await client.send(new HeadBucketCommand({ Bucket: options.bucket }), {
50
+ abortSignal: signal
51
+ });
52
+ return { status: "healthy", detail: options.bucket };
53
+ },
54
+ {
55
+ redact: [
56
+ options.credentials?.accessKeyId ?? "",
57
+ options.credentials?.secretAccessKey ?? ""
58
+ ]
59
+ }
60
+ );
61
+ }
62
+ };
63
+ }
64
+
65
+ // src/index.ts
66
+ var optionalString = (value) => typeof value === "string" && value !== "" ? value : void 0;
67
+ function createAdapter(ctx) {
68
+ if (ctx.slot !== "media") {
69
+ throw new ConfigError(`@nymbalhq/media-s3 serves media, not ${ctx.slot}`, {
70
+ code: "adapter.wrong_slot",
71
+ context: { slot: ctx.slot }
72
+ });
73
+ }
74
+ const bucket = optionalString(ctx.config["bucket"]);
75
+ if (bucket === void 0) {
76
+ throw new ConfigError("@nymbalhq/media-s3 needs a bucket", {
77
+ code: "config.invalid",
78
+ context: { slot: ctx.slot }
79
+ });
80
+ }
81
+ const options = { bucket };
82
+ for (const field of ["region", "endpoint", "prefix", "publicUrl"]) {
83
+ const value = optionalString(ctx.config[field]);
84
+ if (value !== void 0) options[field] = value;
85
+ }
86
+ if (typeof ctx.config["forcePathStyle"] === "boolean") {
87
+ options.forcePathStyle = ctx.config["forcePathStyle"];
88
+ }
89
+ return createS3MediaStorage(options);
90
+ }
91
+ export {
92
+ DEFAULT_S3_REGION,
93
+ S3_MEDIA_CAPABILITIES,
94
+ createAdapter,
95
+ createS3MediaStorage,
96
+ s3ObjectUrl
97
+ };
98
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/s3-media-storage.ts"],"sourcesContent":["import { ConfigError, type AdapterContext, type MediaStorage } from '@nymbalhq/types'\nimport { createS3MediaStorage, type S3MediaStorageOptions } from './s3-media-storage.js'\n\nexport {\n createS3MediaStorage,\n DEFAULT_S3_REGION,\n S3_MEDIA_CAPABILITIES,\n s3ObjectUrl,\n type S3MediaStorageOptions,\n type S3ObjectUrlOptions,\n} from './s3-media-storage.js'\n\nconst optionalString = (value: unknown): string | undefined =>\n typeof value === 'string' && value !== '' ? value : undefined\n\n/**\n * The kernel's entry: `infrastructure.media: 's3'`, with the `s3` block as the platform resolved it (its environment\n * fallbacks and defaults applied). Credentials are never in it: the AWS SDK's default chain provides them.\n */\nexport function createAdapter(ctx: AdapterContext): MediaStorage {\n if (ctx.slot !== 'media') {\n throw new ConfigError(`@nymbalhq/media-s3 serves media, not ${ctx.slot}`, {\n code: 'adapter.wrong_slot',\n context: { slot: ctx.slot },\n })\n }\n const bucket = optionalString(ctx.config['bucket'])\n if (bucket === undefined) {\n throw new ConfigError('@nymbalhq/media-s3 needs a bucket', {\n code: 'config.invalid',\n context: { slot: ctx.slot },\n })\n }\n const options: S3MediaStorageOptions = { bucket }\n for (const field of ['region', 'endpoint', 'prefix', 'publicUrl'] as const) {\n const value = optionalString(ctx.config[field])\n if (value !== undefined) options[field] = value\n }\n if (typeof ctx.config['forcePathStyle'] === 'boolean') {\n options.forcePathStyle = ctx.config['forcePathStyle']\n }\n return createS3MediaStorage(options)\n}\n","import { HeadBucketCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'\nimport { boundedHealthCheck, type HealthReport, type MediaStorage } from '@nymbalhq/types'\n\n/** Where the URL of an object starts, and where its key starts in the bucket. */\nexport interface S3ObjectUrlOptions {\n bucket: string\n /** `us-east-1` when not given. */\n region?: string\n /** An S3-compatible API (MinIO, R2); its objects are served path-style under it. */\n endpoint?: string\n /** Prepended to every key, its edge slashes trimmed; none when empty. */\n prefix?: string\n /** Where the bucket's objects are served from (a CDN), when not the bucket's own URL. */\n publicUrl?: string\n}\n\nexport interface S3MediaStorageOptions extends S3ObjectUrlOptions {\n /** True by default when an endpoint is set: an S3-compatible server rarely has per-bucket hostnames. */\n forcePathStyle?: boolean\n /** For tests only: a store gets its credentials from the AWS SDK's default chain. */\n credentials?: { accessKeyId: string; secretAccessKey: string }\n}\n\nexport const S3_MEDIA_CAPABILITIES: readonly string[] = ['put']\n\nexport const DEFAULT_S3_REGION = 'us-east-1'\n\nconst trimTrailingSlashes = (s: string): string => s.replace(/\\/+$/, '')\n\n/** The object's key in the bucket. Only the prefix is normalised: the key stays verbatim, so distinct keys stay distinct objects. */\nfunction objectKey(prefix: string | undefined, key: string): string {\n const trimmed = (prefix ?? '').replace(/^\\/+|\\/+$/g, '')\n return trimmed === '' ? key : `${trimmed}/${key}`\n}\n\nfunction baseUrl(options: S3ObjectUrlOptions): string {\n if (options.publicUrl !== undefined) return trimTrailingSlashes(options.publicUrl)\n if (options.endpoint !== undefined) {\n return `${trimTrailingSlashes(options.endpoint)}/${options.bucket}`\n }\n return `https://${options.bucket}.s3.${options.region ?? DEFAULT_S3_REGION}.amazonaws.com`\n}\n\n/** The public URL of the object stored under `key`: the base, then each segment of the object key percent-encoded. */\nexport function s3ObjectUrl(options: S3ObjectUrlOptions, key: string): string {\n const path = objectKey(options.prefix, key).split('/').map(encodeURIComponent).join('/')\n return `${baseUrl(options)}/${path}`\n}\n\n/**\n * Media stored as objects in a bucket the operator owns: it never creates buckets or sets policies, so the bucket (or\n * the `publicUrl` in front of it) must already serve its objects publicly.\n */\nexport function createS3MediaStorage(options: S3MediaStorageOptions): MediaStorage {\n const client = new S3Client({\n region: options.region ?? DEFAULT_S3_REGION,\n forcePathStyle: options.forcePathStyle ?? options.endpoint !== undefined,\n ...(options.endpoint !== undefined && { endpoint: options.endpoint }),\n ...(options.credentials !== undefined && { credentials: options.credentials }),\n })\n return {\n capabilities: () => S3_MEDIA_CAPABILITIES,\n async put(key, body, contentType) {\n await client.send(\n new PutObjectCommand({\n Bucket: options.bucket,\n Key: objectKey(options.prefix, key),\n Body: body,\n ContentType: contentType,\n }),\n )\n return { url: s3ObjectUrl(options, key) }\n },\n // HeadBucket reads the bucket's existence and our access to it; aborted at the bound, retries included.\n healthCheck(): Promise<HealthReport> {\n return boundedHealthCheck(\n async (signal) => {\n await client.send(new HeadBucketCommand({ Bucket: options.bucket }), {\n abortSignal: signal,\n })\n return { status: 'healthy', detail: options.bucket }\n },\n {\n redact: [\n options.credentials?.accessKeyId ?? '',\n options.credentials?.secretAccessKey ?? '',\n ],\n },\n )\n },\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@nymbalhq/media-s3",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.1.0",
4
+ "description": "S3 media storage for Nymbal: `infrastructure.media: 's3'`, on AWS or any S3-compatible bucket",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/nymbalhq/nymbal.git",
9
+ "directory": "packages/media-s3"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ },
25
+ "./recipes/*": "./recipes/*"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "recipes"
30
+ ],
31
+ "dependencies": {
32
+ "@nymbalhq/types": "1.1.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@aws-sdk/client-s3": "^3.600.0"
36
+ },
37
+ "devDependencies": {
38
+ "@aws-sdk/client-s3": "^3.1153.0",
39
+ "tsup": "^8.3.5",
40
+ "typescript": "^5.6.3"
41
+ },
42
+ "scripts": {
43
+ "build": "tsup",
44
+ "dev": "tsup --watch",
45
+ "typecheck": "tsc -p tsconfig.json --noEmit"
46
+ }
6
47
  }
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "s3",
3
+ "summary": "Store product images and other media in an S3-compatible bucket (AWS S3, R2, MinIO).",
4
+ "install": ["@nymbalhq/media-s3", "@aws-sdk/client-s3"],
5
+ "set": [{ "path": "infrastructure.media", "value": "s3" }],
6
+ "env": [
7
+ {
8
+ "name": "S3_BUCKET",
9
+ "example": "my-store-media",
10
+ "required": true,
11
+ "comment": "The bucket media is stored in; it must already serve its objects publicly"
12
+ },
13
+ {
14
+ "name": "S3_ENDPOINT",
15
+ "example": "http://localhost:9000",
16
+ "required": false,
17
+ "comment": "An S3-compatible API (MinIO, R2); leave unset for AWS"
18
+ },
19
+ {
20
+ "name": "S3_PUBLIC_URL",
21
+ "example": "https://media.example.com",
22
+ "required": false,
23
+ "comment": "Where the bucket's objects are served from (a CDN); defaults to the bucket's own URL"
24
+ },
25
+ {
26
+ "name": "NYMBAL_S3_PREFIX",
27
+ "example": "media",
28
+ "required": false,
29
+ "comment": "Starts every object key; none by default"
30
+ },
31
+ {
32
+ "name": "AWS_REGION",
33
+ "example": "us-east-1",
34
+ "required": false,
35
+ "comment": "The bucket's region (default us-east-1)"
36
+ },
37
+ {
38
+ "name": "AWS_ACCESS_KEY_ID",
39
+ "example": "",
40
+ "required": false,
41
+ "comment": "Credentials for the AWS SDK; a profile or a role works instead"
42
+ },
43
+ {
44
+ "name": "AWS_SECRET_ACCESS_KEY",
45
+ "example": "",
46
+ "required": false,
47
+ "comment": "The secret for AWS_ACCESS_KEY_ID"
48
+ }
49
+ ],
50
+ "next": [
51
+ "Create the bucket and let it serve its objects publicly: the store never creates buckets or sets policies.",
52
+ "Give the AWS SDK credentials: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env, a profile or a role."
53
+ ]
54
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.